Skip to content

sdk: concurrent f.llm calls beyond ~5 are dispatched after their 30s lease has expired - #564

Closed
agent-relay-code[bot] wants to merge 2 commits into
mainfrom
relayflow/flows-software-garden-860ae350
Closed

agent-relay-code[bot] wants to merge 2 commits into
mainfrom
relayflow/flows-software-garden-860ae350

Conversation

@agent-relay-code

@agent-relay-code agent-relay-code Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Fix authored LLM preflight starving worker leases

Implementation commit: 78f7012; evidence commit: b84c841.

Concurrent authored calls ran synchronous CLI identification and model-readiness
probes before slot admission. Each later call blocked the same Node event loop
that receives dispatches and renews leases. WorkerSlots already bounded admission
correctly; the worker simply could not handle its dispatch before the deadline.
A cold first probe could also expire the durable root lease.

The reported five-versus-nine threshold is consistent with this mechanism:
the first child is exposed to approximately (N−1) × probe time. A 4–7 second
probe stays below 30 seconds at five calls and exceeds it at nine. The new slow
regression also fails on the unchanged base with
journal step "llm-1" completed with lease_expired
(literal command and output).

Changes

  • Share the existing preflight outcome map for one authored execution, with
    single-flight handling of concurrent cold calls. Preserve CLI/source/model/
    managed-mode keys and step-specific refusal diagnostics.
  • Run cold CLI probes asynchronously, including executable lookup. A shared
    generator owns identification, managed-wrapper handling, model readiness,
    auth fallback and redaction; synchronous flows check drives the same logic.
  • Extract the static resolution stage so malformed specs and forbidden models
    still refuse before any provider probe. This is broader than exporting only
    resolveCli in the reviewed plan: that alone would bypass static refusals
    during cache warming. There is no deferred-probe pass treated as successful.
  • Cache successful communication-environment checks per execution.
  • Add real-kernel tests for nine calls at capacities 1 and 4, probe counts,
    maximum execution overlap and every run journal. A durable-root test uses two
    registered models with 45-second cold probes, so cache alone cannot pass it.
    Add probe-driver parity and static-refusal regression tests.

The generator approach avoids introducing a separately packaged worker-thread
entry into the standalone CLI's embedded Node payload. Moving preflight inside
WorkerSlots would only bound child exposure by capacity and would leave the
root lease exposed.

Verification

All literal commands and captured output are in the
evidence ledger.

Typechecking and the SDK build completed; outputs are in the ledger.
The complete npm test run is not green:
67 failed, 2810 passed, 26 skipped, 2 errors.
Failures include unavailable bubblewrap, missing Surface/runtime fixtures,
Bun 1.3.6 versus the required 1.4.0, and flow-handle identity errors.
The 15 hosted-isolation failures reproduce on the unchanged base;
no claim is made that every full-suite failure was baseline-verified.

The standalone CLI builds, but its authored-import smoke test refuses on
both changed and base builds.
The provider passes above use the Node CLI.

Tradeoffs and scope

Cached credentials revoked mid-execution are discovered by execution
(worker_error) rather than the next preflight auth refusal; a new execution
probes again. Failed probe facts are also scoped to that execution.
The first communication-environment check still uses its existing synchronous
10-second local broker probe; subsequent successful checks are reused.
This is not a claim that every SDK subprocess is asynchronous.

No kernel, lease duration, WorkerSlots, fatal-error handling (#560), gate, or
workflow changes. The prompt-lab branch had unrelated history, so verification
used its isolated worktree with this checkout's built CLI; no example branch
was merged or pushed.

Checks

The checks fail on the base commit too, so these failures were not introduced by this change: they come from the repository itself or from the environment the checks ran in. This pull request is a draft until someone looks.

What ran (.relayflow/check.sh)
#!/bin/sh
# How this repository checks itself on a fresh machine.
#
# Mirrors the three PR-triggered GitHub workflows, in the order they run their
# own steps:
#
#   .github/workflows/cloud-runtime-artifact.yml  kernel + SDK
#   .github/workflows/surface-package.yml         authoring surface
#   .github/workflows/schema-publish.yml          JSON schema (validate job)
#
# Deliberately NOT run here, and why:
#
#   * .github/workflows/review-swarm.yml — the agent review gate. It requires
#     CLOUD_API_URL, CLOUD_API_KEY and RELAY_WORKSPACE_KEY plus a reachable
#     Agent Relay cloud workspace. Secrets and a service this machine has not
#     got.
#   * cloud-runtime-artifact.yml's "Build standalone flows CLI" / "Assemble
#     artifact" / "Smoke exact Linux artifact" / upload-artifact steps — those
#     run AFTER the test steps and package a release tarball; they are
#     packaging and publication, not checks.
#   * .github/workflows/publish.yml — workflow_dispatch/release only, publishes
#     to npm with a trusted publisher. Never runs on a PR.
#
# Version drift worth knowing about: CI pins node 22 and bun 1.4.0 via
# setup-node / setup-bun. This script uses whatever node and bun the machine
# already has rather than installing a version manager, so a failure that
# reproduces only here may be a toolchain-version difference.

set -e

repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P)
cd "$repo_root"

echo "==> repo: $repo_root"

# ---------------------------------------------------------------------------
# Toolchain: bun
#
# CI pins bun through oven-sh/setup-bun@v2 with bun-version 1.4.0 in all four
# workflows. That pin is not cosmetic: tests/authored-node-runtime.test.ts
# asserts the exact version in beforeAll
#
#   expect(spawnSync(bun, ['--version'], ...).stdout.trim()).toBe('1.4.0')
#
# and fails its whole suite on any other bun, because the standalone CLI it
# builds embeds that bun's runtime. A machine carrying a different bun (this
# one shipped 1.3.6 alongside node) therefore cannot run the SDK suite at all
# until the pinned version is present. Installed per-user under ~/.bun, ahead
# of whatever bun is already on PATH, and never installed twice.
# ---------------------------------------------------------------------------
bun_pin=1.4.0
if [ "$(bun --version 2>/dev/null)" != "$bun_pin" ]; then
  if [ "$("$HOME/.bun/bin/bun" --version 2>/dev/null)" != "$bun_pin" ]; then
    echo "==> installing bun $bun_pin (CI pins it through setup-bun)"
    curl -fsSL --max-time 300 https://bun.sh/install | bash -s "bun-v$bun_pin"
  fi
  PATH="$HOME/.bun/bin:$PATH"
  export PATH
fi

echo "==> node: $(node --version)  npm: $(npm --version)  bun: $(bun --version)"

# ---------------------------------------------------------------------------
# Toolchain: Rust
#
# CI gets this from dtolnay/rust-toolchain@stable and then calls plain `cargo`.
# Plain cargo here too, NOT ops/cargo.sh: that wrapper redirects RUSTUP_HOME
# and CARGO_TARGET_DIR outside the repo for a cloud sandbox's file-size cap,
# which would leave kernel/target/release/relayflowd — the path the SDK suite
# and the source-checkout binary lookup expect — empty.
# ---------------------------------------------------------------------------
if ! command -v cargo >/dev/null 2>&1; then
  if [ -x "$HOME/.cargo/bin/cargo" ]; then
    PATH="$HOME/.cargo/bin:$PATH"
  else
    echo "==> installing a stable Rust toolchain (kernel/Cargo.toml is edition 2024)"
    curl --proto '=https' --tlsv1.2 -sSf --max-time 300 https://sh.rustup.rs \
      | sh -s -- -y --default-toolchain stable --profile minimal --no-modify-path
    PATH="$HOME/.cargo/bin:$PATH"
  fi
  export PATH
fi
echo "==> cargo: $(cargo --version)"

# ---------------------------------------------------------------------------
# cloud-runtime-artifact.yml: "Test artifact contract"
# Node builtins only; runs before any install in CI too.
# ---------------------------------------------------------------------------
echo "==> [cloud-runtime] artifact contract test"
node --test scripts/cloud-artifact.test.mjs

# ---------------------------------------------------------------------------
# cloud-runtime-artifact.yml: "Build relayflowd" + "Test kernel"
# ---------------------------------------------------------------------------
echo "==> [cloud-runtime] build relayflowd (release)"
(cd kernel && cargo build --locked --release -p relayflowd)

echo "==> [cloud-runtime] kernel test suite"
(cd kernel && cargo test --workspace)

# ---------------------------------------------------------------------------
# cloud-runtime-artifact.yml: "Provision hosted extension sandbox"
#
# packages/sdk/tests/babysitter-native-extension.test.ts skips itself without
# /usr/bin/bwrap, so the sandbox cases silently stop gating when it is absent.
# Installing it needs root; if passwordless sudo is not available the run
# continues with those cases skipped rather than failing.
# ---------------------------------------------------------------------------
if command -v bwrap >/dev/null 2>&1; then
  echo "==> [cloud-runtime] bubblewrap already present: $(bwrap --version)"
elif sudo -n true >/dev/null 2>&1; then
  echo "==> [cloud-runtime] provisioning bubblewrap"
  sudo -n apt-get update
  sudo -n apt-get install --yes --no-install-recommends bubblewrap
  # Ubuntu 24.04 blocks unprivileged user namespaces through AppArmor, which
  # is the facility the production sandbox command uses.
  if sudo -n sysctl kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
    # A container without CAP_SYS_ADMIN over its own sysctls refuses this
    # write. That only costs the sandbox cases their coverage (they skip
    # themselves), so it must not abort the whole check under `set -e`.
    sudo -n sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \
      || echo "==> [cloud-runtime] NOTE: sysctl write denied; userns sandbox cases may skip"
  fi
  bwrap --version
else
  echo "==> [cloud-runtime] SKIP bubblewrap: not installed and no passwordless"
  echo "    sudo on this machine. The hosted-extension sandbox tests skip"
  echo "    themselves; everything else in the SDK suite still runs."
fi

# ---------------------------------------------------------------------------
# surface-package.yml: the whole job is one script.
#
# It installs and builds packages/surface, runs its vitest suite, typechecks
# the regressions and examples trees, packs a tarball, `npm ci`s the SDK,
# overrides the registry surface with the packed one, typechecks the SDK,
# builds a throwaway consumer against the tarball, and finishes with the SDK's
# authored-flow test. Running it here keeps that gate's exact semantics rather
# than an approximation of them.
# ---------------------------------------------------------------------------
echo "==> [surface-package] scripts/surface-package-gate.sh"
bash scripts/surface-package-gate.sh

# ---------------------------------------------------------------------------
# cloud-runtime-artifact.yml: SDK dependency wiring.
#
# The gate above left packages/sdk/node_modules holding the PACKED surface.
# The cloud-runtime job instead installs the built directory, and links the
# same copy at the repo root so `workflows/*.flow.ts` — which import
# @relayflows/surface from the root, where there is no node_modules — resolve.
# The `./` prefix is load-bearing: without it npm reads "packages/surface" as
# a GitHub org/repo shorthand.
# ---------------------------------------------------------------------------
echo "==> [cloud-runtime] install SDK dependencies"
npm ci --prefix packages/sdk --ignore-scripts
npm install ./packages/surface --prefix packages/sdk --no-save --ignore-scripts

echo "==> [cloud-runtime] link the local surface at the repo root"
mkdir -p node_modules/@relayflows
ln -sfn ../../packages/sdk/node_modules/@relayflows/surface \
  node_modules/@relayflows/surface
node -e "console.log(require.resolve('@relayflows/surface'))"

# ---------------------------------------------------------------------------
# cloud-runtime-artifact.yml: "Test SDK and type-level authoring contracts"
#
# This is `npm test` expanded minus test:prep, exactly as CI expands it.
# test:prep shells out to ops/cargo.sh only to produce a debug relayflowd; the
# release binary built above is pointed at through RELAYFLOWD_BIN instead. Its
# other half — re-asserting the executable bit on the preflight CLI fixtures —
# is kept, because the failure it prevents is an opaque EACCES inside a
# preflight test.
#
# RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 for the same reason CI sets it: one case in
# tests/live-kernel.test.ts drives the real Claude analyzer and fails closed
# when it cannot. That needs a `claude` binary and model access, which this
# machine has not got. Setting it means THIS RUN IS NOT GATE-2 ACCEPTANCE
# EVIDENCE; the skipped case says so in its own output. Everything else runs.
# ---------------------------------------------------------------------------
echo "==> [cloud-runtime] SDK suite"
[ ! -d testdata/preflight ] || \
  find testdata/preflight -name '*-cli' -type f -exec chmod +x {} +

# The eight node fixtures in testdata/preflight are EXTENSIONLESS (they stand
# in for real agent CLIs, which have no extension) and every one of them is
# ESM: `import { receiveWrapperRequest } from './wrapper-session.mjs'`.
#
# Node decides an extensionless file's module system from the NEAREST
# package.json, walking up without stopping at the repo. The repo has no root
# package.json, so in CI the walk finds nothing, module-syntax detection
# applies, and the fixtures load as ESM. On this machine the checkout lives
# under $HOME, and /home/daytona/package.json — an unrelated project, outside
# the repo and not ours to edit — declares "type": "commonjs". That disables
# detection: node 25 then runs each fixture as CommonJS and exits 0 having
# produced no output at all, so the step it backs completes with a null
# output. Seven tests/live-kernel.test.ts cases fail that way, every one of
# them a case that runs a fixture IN PLACE; the cases that first copy the
# fixture into a temp dir pass, because /tmp has no such ancestor.
#
# Restore CI's answer for this directory only. Written, not committed: it
# compensates for where this checkout happens to sit, and all eleven other
# fixtures there are /bin/sh, so the declaration cannot mislabel one.
fixture_pkg="$repo_root/testdata/preflight/package.json"
if [ -d testdata/preflight ] && [ ! -e "$fixture_pkg" ]; then
  echo '{"type":"module"}' > "$fixture_pkg"
  trap 'rm -f "$fixture_pkg"' EXIT INT TERM
fi

cd packages/sdk
npm run typecheck
npm run build
npm run typecheck:tests
RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 \
RELAYFLOWD_BIN="$repo_root/kernel/target/release/relayflowd" \
  ./node_modules/.bin/vitest run
cd "$repo_root"

# ---------------------------------------------------------------------------
# schema-publish.yml, validate job. The publish and Pages jobs below it need
# npm and GitHub Pages credentials and are left out.
#
# The generator must be idempotent and its output must already be committed,
# so the git diff is part of the check, not a side effect.
# ---------------------------------------------------------------------------
echo "==> [schema] regenerate and check the committed schema"
node scripts/generate-json-schema.mjs
git diff --exit-code -- packages/schema/flows.schema.json
cp packages/schema/flows.schema.json /tmp/flows.schema.first.json
node scripts/generate-json-schema.mjs
diff -q /tmp/flows.schema.first.json packages/schema/flows.schema.json

echo "==> [schema] parity and smoke"
(cd packages/schema && bun run test)

echo "==> ALL CHECKS PASSED"
Output on this branch (last 80 lines)
 ❯ refuse src/hosted-extension-protocol.ts:135:21
 ❯ ChildProcess.<anonymous> src/hosted-extension-protocol.ts:234:21

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { code: 'plugin_unsupported' }
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[14/24]⎯

 FAIL  tests/hosted-extension-protocol.test.ts > hosted extension hostile protocol > rejects an import-time different PR frame with zero adapter calls
 FAIL  tests/hosted-extension-protocol.test.ts > hosted extension hostile protocol > rejects an import-time different delivery frame with zero adapter calls
 FAIL  tests/hosted-extension-protocol.test.ts > hosted extension hostile protocol > rejects an import-time different event frame with zero adapter calls
AssertionError: expected Error: Hosted extension sandbox exited wi… { code: '…' } to match object { code: 'plugin_event_unroutable' }

- Expected
+ Received

- Object {
-   "code": "plugin_event_unroutable",
+ PluginError {
+   "code": "plugin_unsupported",
  }

 ❯ tests/hosted-extension-protocol.test.ts:430:5
    428|   ])('rejects an import-time %s frame with zero adapter calls', async …
    429|     let calls = 0;
    430|     await expect(runVerifiedNativeExtensionSandbox({
       |     ^
    431|       artifact: await artifact(hostileImport([frame, { type: 'error', …
    432|       manifest: validateFlowExtensionManifest(manifest()), dispatch: d…

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[15/24]⎯

 FAIL  tests/hosted-extension-protocol.test.ts > hosted extension hostile protocol > rejects two forged calls after the authoritative first outcome settles
 FAIL  tests/hosted-extension-protocol.test.ts > hosted extension hostile protocol > waits for a pending adapter to reject after a forged child error
 FAIL  tests/hosted-extension-protocol.test.ts > hosted extension hostile protocol > waits for a pending adapter to resolve after a forged child error
 FAIL  tests/hosted-extension-protocol.test.ts > hosted extension hostile protocol > returns a typed adapter rejection even when the hostile child hangs
Error: hostile child did not invoke the adapter
 ❯ Timeout._onTimeout tests/hosted-extension-protocol.test.ts:118:45
    116| async function waitForInvocation(invoked: Promise<void>): Promise<void…
    117|   await new Promise<void>((resolve, reject) => {
    118|     const timeout = setTimeout(() => reject(new Error('hostile child d…
       |                                             ^
    119|     void invoked.then(() => { clearTimeout(timeout); resolve(); }, rej…
    120|   });

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[16/24]⎯

⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯

Vitest caught 1 unhandled error during the test run.
This might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.

⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯
Error: Hosted extension sandbox exited without a valid completion (exit 1): bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted

 ❯ refuse src/hosted-extension-protocol.ts:135:21
    133|       : new PluginError('plugin_unsupported', 'Hosted capability rejec…
    134|     const refuse = (message: string) => {
    135|       const error = new PluginError('plugin_unsupported', message);
       |                     ^
    136|       CHILD_PROCESS_KILL(child, 'SIGKILL');
    137|       if (capabilityState === 'pending') {
 ❯ ChildProcess.<anonymous> src/hosted-extension-protocol.ts:234:21
 ❯ ChildProcess.emit node:events:520:22
 ❯ maybeClose node:internal/child_process:1084:16
 ❯ Process.ChildProcess._handle.onexit node:internal/child_process:304:5

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { code: 'plugin_unsupported' }
This error originated in "tests/hosted-extension-protocol.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
The latest test that might've caused the error is "rejects two forged calls after the authoritative first outcome settles". It might mean one of the following:
- The error was thrown, while Vitest was running this test.
- If the error occurred after the test had been completed, this was the last documented test before it was thrown.
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 Test Files  5 failed | 179 passed | 1 skipped (185)
      Tests  24 failed | 2878 passed | 4 skipped (2906)
     Errors  1 error
   Start at  06:47:03
   Duration  309.75s (transform 3.03s, setup 0ms, collect 47.95s, tests 802.99s, environment 23ms, prepare 7.33s)

Output on the base commit (last 80 lines)
 ❯ refuse src/hosted-extension-protocol.ts:135:21
 ❯ ChildProcess.<anonymous> src/hosted-extension-protocol.ts:234:21

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { code: 'plugin_unsupported' }
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[14/24]⎯

 FAIL  tests/hosted-extension-protocol.test.ts > hosted extension hostile protocol > rejects an import-time different PR frame with zero adapter calls
 FAIL  tests/hosted-extension-protocol.test.ts > hosted extension hostile protocol > rejects an import-time different delivery frame with zero adapter calls
 FAIL  tests/hosted-extension-protocol.test.ts > hosted extension hostile protocol > rejects an import-time different event frame with zero adapter calls
AssertionError: expected Error: Hosted extension sandbox exited wi… { code: '…' } to match object { code: 'plugin_event_unroutable' }

- Expected
+ Received

- Object {
-   "code": "plugin_event_unroutable",
+ PluginError {
+   "code": "plugin_unsupported",
  }

 ❯ tests/hosted-extension-protocol.test.ts:430:5
    428|   ])('rejects an import-time %s frame with zero adapter calls', async …
    429|     let calls = 0;
    430|     await expect(runVerifiedNativeExtensionSandbox({
       |     ^
    431|       artifact: await artifact(hostileImport([frame, { type: 'error', …
    432|       manifest: validateFlowExtensionManifest(manifest()), dispatch: d…

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[15/24]⎯

 FAIL  tests/hosted-extension-protocol.test.ts > hosted extension hostile protocol > rejects two forged calls after the authoritative first outcome settles
 FAIL  tests/hosted-extension-protocol.test.ts > hosted extension hostile protocol > waits for a pending adapter to reject after a forged child error
 FAIL  tests/hosted-extension-protocol.test.ts > hosted extension hostile protocol > waits for a pending adapter to resolve after a forged child error
 FAIL  tests/hosted-extension-protocol.test.ts > hosted extension hostile protocol > returns a typed adapter rejection even when the hostile child hangs
Error: hostile child did not invoke the adapter
 ❯ Timeout._onTimeout tests/hosted-extension-protocol.test.ts:118:45
    116| async function waitForInvocation(invoked: Promise<void>): Promise<void…
    117|   await new Promise<void>((resolve, reject) => {
    118|     const timeout = setTimeout(() => reject(new Error('hostile child d…
       |                                             ^
    119|     void invoked.then(() => { clearTimeout(timeout); resolve(); }, rej…
    120|   });

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[16/24]⎯

⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯

Vitest caught 1 unhandled error during the test run.
This might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.

⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯
Error: Hosted extension sandbox exited without a valid completion (exit 1): bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted

 ❯ refuse src/hosted-extension-protocol.ts:135:21
    133|       : new PluginError('plugin_unsupported', 'Hosted capability rejec…
    134|     const refuse = (message: string) => {
    135|       const error = new PluginError('plugin_unsupported', message);
       |                     ^
    136|       CHILD_PROCESS_KILL(child, 'SIGKILL');
    137|       if (capabilityState === 'pending') {
 ❯ ChildProcess.<anonymous> src/hosted-extension-protocol.ts:234:21
 ❯ ChildProcess.emit node:events:520:22
 ❯ maybeClose node:internal/child_process:1084:16
 ❯ Process.ChildProcess._handle.onexit node:internal/child_process:304:5

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { code: 'plugin_unsupported' }
This error originated in "tests/hosted-extension-protocol.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
The latest test that might've caused the error is "rejects two forged calls after the authoritative first outcome settles". It might mean one of the following:
- The error was thrown, while Vitest was running this test.
- If the error occurred after the test had been completed, this was the last documented test before it was thrown.
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 Test Files  4 failed | 180 passed | 1 skipped (185)
      Tests  24 failed | 2878 passed | 4 skipped (2906)
     Errors  1 error
   Start at  06:53:40
   Duration  316.73s (transform 2.91s, setup 0ms, collect 47.30s, tests 822.88s, environment 23ms, prepare 7.26s)

What the repair agent found

Repair notes — .relayflow/check.sh on this machine

Branch relayflow/flows-software-garden-860ae350, at b84c841.

Three distinct causes were behind the failing run. Two were missing setup and
are now handled in .relayflow/check.sh (uncommitted, as instructed). One is a
property of this container that no step in the script can change; it is
recorded here and left failing.

Fixed in check.sh — 1. the script aborted before it reached any SDK test

The previous .relayflow/check.log ends inside "provisioning bubblewrap". The
script runs under set -e, and

sudo -n sysctl -w kernel.apparmor_restrict_unprivileged_userns=0

exits 1 here, so the whole check died at that line — the surface gate, the SDK
suite and the schema gate never ran at all. That sysctl is best-effort by
intent (its own comment says the sandbox tests "skip themselves" when the
facility is missing), so its failure now prints a note instead of aborting.

$ sudo -n sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
sysctl: permission denied on key "kernel.apparmor_restrict_unprivileged_userns"

Fixed in check.sh — 2. bun 1.4.0

tests/authored-node-runtime.test.ts asserts the exact bun version in
beforeAll, because the standalone CLI it builds embeds that bun's runtime:

 FAIL  tests/authored-node-runtime.test.ts [ tests/authored-node-runtime.test.ts ]
AssertionError: expected '1.3.6' to be '1.4.0' // Object.is equality

Expected: "1.4.0"
Received: "1.3.6"

 ❯ tests/authored-node-runtime.test.ts:18:77

All four workflows pin bun-version: "1.4.0" through oven-sh/setup-bun@v2;
this machine shipped 1.3.6 next to node. check.sh now installs the pinned
version under ~/.bun when the one on PATH does not match, which is the local
equivalent of that CI step. After it, the suite is green:

$ PATH="$HOME/.bun/bin:$PATH" RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 \
    RELAYFLOWD_BIN=.../kernel/target/release/relayflowd \
    ./node_modules/.bin/vitest run tests/authored-node-runtime.test.ts
 ✓ tests/authored-node-runtime.test.ts (14 tests) 92599ms
  Test Files  1 passed (1)
       Tests  14 passed (14)

Fixed in check.sh — 3. extensionless ESM fixtures under a CommonJS ancestor

Seven tests/live-kernel.test.ts cases failed with a null step output:

× ... runs hn-monitor analyze-story end-to-end via a stub agent CLI
  → expected { …(12) } to match object { output: { …(3) }, …(1) }
× ... agent step preserves the CliResult wrapper as output ...
  → expected null not to be null
× ... AgentWorker exposes wake_context to the CLI ...
  → Cannot read properties of null (reading 'story_title')

The eight node fixtures in testdata/preflight are extensionless (they stand
in for real agent CLIs) and all eight are ESM. Node picks an extensionless
file's module system from the nearest package.json, walking up past the repo
root. The repo has none, so in CI the walk finds nothing and module-syntax
detection loads them as ESM. This checkout sits under $HOME, where
/home/daytona/package.json — an unrelated project, outside the repo — sets
"type": "commonjs". That disables detection, and node 25 then runs the
fixture as CommonJS, exiting 0 with no output whatsoever:

$ printf '{"protocol":"relayflows-agent-cli-v1","instruction":"hi"}' \
    | node testdata/preflight/analyze-story-stub-cli --relayflows-adapter-v1
EXIT=0

# byte-identical copy at /tmp, which has no such ancestor:
$ printf '{"protocol":"relayflows-agent-cli-v1","instruction":"hi"}' \
    | node /tmp/stub-noext --relayflows-adapter-v1
relayflows-agent-cli-v1
relayflows-agent-cli-v1-execute
{"story_title":"stub","relevance_score":5,"reasoning":"stub agent runtime …"}

This is exactly why the in-place cases failed while the cases that first copy a
fixture into a temp dir passed. check.sh now writes
testdata/preflight/package.json = {"type":"module"} for the duration of the
run and removes it on exit. The other eleven fixtures in that directory are
/bin/sh, so the declaration cannot mislabel one. After it:

 ✓ tests/live-kernel.test.ts (31 tests | 1 skipped) 52396ms
  Test Files  1 passed (1)
       Tests  30 passed | 1 skipped (31)

A one-line committed testdata/preflight/package.json would fix this for any
checkout location, not just this one. That is a repo change unrelated to the
issue under work, so it is left for a human to decide rather than folded in
here.

NOT fixed — bubblewrap cannot create a usable sandbox in this container

22 failures remain, all in the three hosted-extension sandbox suites:

 ❯ tests/hosted-extension-isolation.test.ts (22 tests | 13 failed)
 ❯ tests/hosted-extension-protocol.test.ts  (24 tests |  8 failed)
 ❯ tests/babysitter-native-extension.test.ts (41 tests | 1 failed)

Every one reduces to the same line:

Error: Hosted extension sandbox exited without a valid completion (exit 1):
bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted

Reproduced outside the test suite, with no repo code involved:

$ bwrap --ro-bind /usr /usr --ro-bind /bin /bin --ro-bind /lib /lib \
    --ro-bind /lib64 /lib64 --unshare-all /bin/true
bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted
EXIT=1

$ bwrap ... --unshare-user --unshare-pid /bin/true      # no --unshare-net
bwrap: setting up uid map: Permission denied
EXIT=1

The process runs with CapEff: 0000000000000000 and a seccomp filter
(Seccomp: 2), and kernel.apparmor_restrict_unprivileged_userns is 1 and
cannot be written even as root:

$ sudo -n sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
sysctl: permission denied on key "kernel.apparmor_restrict_unprivileged_userns"

CI relaxes that sysctl; this container will not allow it. Three other routes
were tried and all are refused by bubblewrap 0.12.0 as Debian builds it:

$ sudo -n chmod u+s /usr/bin/bwrap && bwrap ... --unshare-all /bin/true
bwrap: setuid use of bubblewrap is not supported
$ sudo -n setcap cap_sys_admin,cap_net_admin,cap_sys_chroot+ep /usr/bin/bwrap
$ bwrap ... --unshare-all /bin/true
bwrap: Unexpected capabilities but not setuid, old file caps config?

(/usr/bin/bwrap was restored to its packaged state afterwards: mode
-rwxr-xr-x, no file capabilities.) Running it as root does work
(sudo -n bwrap ... --unshare-all /bin/true exits 0), but the suite runs
unprivileged and running vitest as root would rewrite ownership across the
checkout, so that was not done.

These failures are not from this branch. The same test fails identically at the
branch's base commit f6ece41, in a clean worktree:

$ git worktree add --detach /tmp/flows-base-check f6ece41
$ cd /tmp/flows-base-check/packages/sdk
$ ./node_modules/.bin/vitest run tests/babysitter-native-extension.test.ts
Caused by: Error: Hosted extension sandbox exited without a valid completion
(exit 1): bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted
 Test Files  1 failed (1)
      Tests  1 failed | 40 passed (41)

check.sh deliberately installs bubblewrap when it is absent, because
babysitter-native-extension.test.ts skips its sandbox case without
/usr/bin/bwrap and would silently stop gating. Installing a bubblewrap that
cannot unshare does not restore that gate — it only converts the skip into a
failure — but leaving bubblewrap uninstalled would not have helped either:
hosted-extension-isolation.test.ts and hosted-extension-protocol.test.ts
carry no skip guard and fail on a missing /usr/bin/bwrap just the same.

Where the run stands now

sh .relayflow/check.sh with the two setup fixes above:

 ✓ tests/authored-parallel-llm.test.ts (6 tests) 115857ms
   ✓ parallel llm capacity 1 > deduplicates concurrent preflight probes 2590ms
   ✓ parallel llm capacity 1 > completes nine calls without expired child leases during slow preflight 8713ms
   ✓ parallel llm capacity 1 > keeps the durable root lease alive across two cold models 47762ms
   ✓ parallel llm capacity 4 > deduplicates concurrent preflight probes 1311ms
   ✓ parallel llm capacity 4 > completes nine calls without expired child leases during slow preflight 7182ms
 ✓ tests/live-kernel.test.ts (31 tests | 1 skipped) 53441ms
 ✓ tests/authored-node-runtime.test.ts (14 tests) 94155ms

  Test Files  3 failed | 181 passed | 1 skipped (185)
       Tests  22 failed | 2877 passed | 4 skipped (2903)

Before: 5 failed | 179 passed, 29 failed | 2856 passed. The 22 that remain
are exactly the three bubblewrap suites (13 + 8 + 1).

The kernel suite and the surface-package gate are green. The SDK suite's
non-zero exit ends the script under set -e, so its last stage never runs;
executed by hand it passes:

$ node scripts/generate-json-schema.mjs
Generated packages/schema/flows.schema.json (72 definitions)
$ git diff --exit-code -- packages/schema/flows.schema.json   # clean, idempotent on a second generate
$ cd packages/schema && bun run test
 77 pass
 0 fail
 3938 expect() calls
Ran 77 tests across 2 files. [1.98s]

The working tree is clean; the temporary testdata/preflight/package.json was
removed by the script's own trap.

Fixes #561


Summary by cubic

Fixes concurrent f.llm calls beyond ~5 failing with lease_expired by making authored CLI preflight probes asynchronous and shared.

Bug Fixes

  • Centralized per-execution preflight outcome cache with single-flight handling for concurrent cold calls.
  • Ran cold CLI probes (identification, CLI, git, auth, and model readiness) asynchronously so the Node.js event loop can still receive dispatches and renew worker leases.
  • Extracted static resolution so malformed specs and forbidden models still refuse before any provider probe.
  • Added caching. Successful communication-environment checks are reused per execution; credentials revoked mid-execution are surfaced by the next fresh probe.
  • Durable root lease and waits for now runs independent of My WSLM2.

Written for commit b84c841. Summary will update on new commits.

Review in cubic

@coderabbitai

coderabbitai Bot commented Sep 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: d9e18754-646d-462d-a93e-5cc33db3fa6b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@khaliqgant

Copy link
Copy Markdown
Member

Shepherd decision (2026-09-24): #576 is the selected candidate for the duplicate concurrent-probe fix pending a focused async-probe check. Your head b84c841 is older and currently fails npm run typecheck && npm run typecheck:tests against the current checkout at authored-flow-executor.ts:438 (the newer #576 head 909a1d2 passes). #564 has valuable 6s/45s async-probe evidence; I will not discard it until verifying/porting the minimal behavior. Please do not push overlapping changes; this PR is a supersede candidate, not closed yet.

@khaliqgant

Copy link
Copy Markdown
Member

The stronger async probe behavior from this PR has been ported onto #576 and pushed as d69cd27b9f17a8a4a4f84958f222fdf3a2a5f9bf (guarded from #576 head 909a1d2). #564 remains open while exact-head CI/review on #576 runs; no overlapping push requested. If #576 proves the behavior, this PR will be superseded with attribution.

@khaliqgant

Copy link
Copy Markdown
Member

Superseded by #576 after guarded verification. The technically correct implementation is now on #576 head 2dab6ae47d2ae5ef0e6bc2c1b64cdb8b69ede4a8, based on current main e04c5b9715d6bc2c3233b92610dd15133010c229. It includes the async probe behavior ported from this PR (d69cd27b...) plus the follow-up Cursor repairs (8ae646aa, 3656b131, 2dab6ae) for stdin isolation and legacy/options-bag compatibility. Focused SDK typechecks and communication/authored/CLI/cache tests pass, and exact-head required checks are green: linux run 36058480822, packed 36058480717, validate 36058480705, guard 36058478311 (Cursor and CodeRabbit statuses also pass). This draft is closed to prevent duplicate ownership; attribution and evidence remain in #576.

@khaliqgant

Copy link
Copy Markdown
Member

Closing as superseded by #576 after exact-head CI and focused behavior verification; no merge performed.

@khaliqgant khaliqgant closed this Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sdk: concurrent f.llm calls beyond ~5 are dispatched after their 30s lease has expired

1 participant