Skip to content

fix(a2a): detach sandbox runs from client streams - #33

Merged
drewstone merged 4 commits into
mainfrom
fix/a2a-detached-execution
Sep 19, 2026
Merged

drewstone merged 4 commits into
mainfrom
fix/a2a-detached-execution

Conversation

@drewstone

Copy link
Copy Markdown
Contributor

Summary

  • Use the Sandbox SDK dispatchPrompt + exact session execution controls for A2A task runs.
  • Persist the accepted sandbox/environment, session, and execution IDs on the task execution fence.
  • Treat response disconnects as observation-only; tasks/resubscribe replays the exact run and tasks/get reconciles its result before lease failure.
  • Restrict remote interruption to explicit tasks/cancel, and fail closed when production adapters lack durable controls.

This follows the Sandbox SDK README “Resume a session” contract and its session.events, session.result, and session.interrupt primitives.

Proof

  • pnpm typecheck
  • pnpm test -- --reporter=dot — 24 files, 392 tests passed
  • Focused disconnect/resubscribe, exact cancel, expired-run recovery, and webhook regression tests included.

drewstone and others added 4 commits September 1, 2026 19:18
Keep both intents from #51 and this branch:
- src/types.ts: retain SandboxPromptOptions and prepareBudgetedPrompt from main
  alongside dispatchPrompt, session controls, and the run control reference.
- tests/a2a.test.ts: keep the durable-sandbox harness and main's authorizeConsumer.
- tests/failed-execution-receipts.test.ts: the A2A failed-receipt cases now run
  through the durable test adapter because A2A execution fails closed without
  dispatchPrompt and session controls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@tangletools tangletools left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved for merge: branch is mergeable against its base, PR is the named change, and its checks (where the repo runs them) are green. Verified live state before approving.

@drewstone
drewstone merged commit d2e45c7 into main Sep 19, 2026
3 checks passed
drewstone added a commit that referenced this pull request Sep 20, 2026
…box surface (#55)

* fix(a2a): answer tasks/get without awaiting the detached run

`session.result()` awaits a terminal result. Reconciling on every
tasks/get with allowActive=true therefore turned a status read into a
long poll that held the request open for the whole agent turn — the
exact coupling the detached-execution fence removed from the stream
path.

Probe the run instead: the durable session contract now carries
`runs()`, and reconciliation skips `result()` while the execution fence
is live and the run is still active. An expired fence still awaits the
result, because there is no owner left to settle the task.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(a2a): emit the resume cursor resubscribe already accepts

`TaskIdParams.lastEventId` and `SandboxStreamEvent.id` landed together,
but the resubscribe stream never wrote an SSE `id:` field, so a client
had no way to learn the cursor it was asked to send back. The resume
path was write-only and untested.

Write `id:` on every artifact-update frame, skipping ids that carry a
line break so a runtime value cannot forge a frame boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(a2a): collapse what the fence PRs left duplicated

- `dispatchDetachedSandboxStreamRich` restated every parameter of
  `dispatchSandboxStreamRich` only to forward them, and guarded at
  runtime against an options argument both call sites always pass.
  Forward the base function's own argument tuple with the sandbox
  factory swapped; the options are now the first, required argument.
- `gatewayExecutionRecovery` was a dead constant in handler.ts plus two
  hardcoded literals. One exported constant, two users.
- `exactReference` normalized the run control ref twice and carried a
  second failure branch that only changed an error message.
- `withTaskExecution`, `clearTaskExecution` and
  `hasExpiredTaskExecution` each took a detour through a nested
  conditional, an IIFE, or a second marker read.
- `hasDetachedSandbox` has no caller outside its module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(a2a): share the fixture the two end-to-end suites copied

a2a.test.ts and a2a-long-horizon.test.ts carried their own copies of the
same agent, stub sandbox, demo key verifier, API key header, message
builder and SSE parser, and the copies had already drifted — one
`textMessage` took positional arguments, the other an options object.
One harness, one shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): upgrade first-party SDKs and pin the adopted sandbox surface

@tangle-network/sandbox 0.37.0 -> 0.43.0, agent-app 0.47.6 -> 0.48.3,
agent-interface 2.3.0 -> 2.10.0, agent-runtime 0.192.2 -> 0.240.0.

The SDK was a devDependency nothing imported, so the detached-execution
surface adopted in #33 was never checked against it. src/sandbox-sdk-
contract.ts asserts what the gateway reads out of the SDK — the
dispatch result, the session result, the run listing, the interrupt
outcome — so an SDK that reshapes any of it fails `pnpm typecheck`.

That check immediately caught a live defect: `SandboxPromptResult`
declared `question?: string`, but the runtime returns
`{ questionId, questions }`. A detached run that finished awaiting a
question therefore put that object straight into the A2A status message
as a text part. Read a prompt out of the payload instead.

agent-app 0.48.3 declares peer ranges for sandbox 0.40.x, agent-runtime
0.231.x and agent-eval 0.182.x, so pnpm reports those peers as unmet at
the newest versions. Typecheck, the full suite and the build are green
on the newest set, and agent-app is a devDependency used by one
composition test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(a2a): probe the run before settling a lapsed execution lease

A lapsed lease says the execution's owner is gone, not that the sandbox
stopped. The expired-lease recovery path still reached the blocking
`session.result()`, so a `tasks/get` against an orphaned-but-live run
hung for the rest of that run — the same failure the status-read guard
had just closed, one branch over.

Probe the run state independently of lease freshness. Reconciliation now
returns early whenever the sandbox still owns the run, and expired-lease
recovery leaves such a task `working` instead of failing it, so a live
run is neither waited on nor abandoned; a later poll settles it from its
result.

Reported by Codex review on #55.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(a2a): read only known terminal statuses as a finished run

`SandboxDurableSession.runs()` accepts any status string, but the probe
recognized only the literal `active`. A host sandbox reporting `running`
or `queued` — both already treated as non-terminal elsewhere in
reconciliation — would be read as finished, and `tasks/get` would fall
into the blocking `session.result()`.

Invert the test: `completed`, `failed`, `cancelled` and `canceled` are
terminal, case-insensitively, and every other status means the sandbox
still owns the run. An unrecognized status now costs one deferred poll
instead of a hung request.

Reported by Codex review on #55.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants