Repository navigation
fix(a2a): unblock tasks/get, emit the resume cursor, and pin the sandbox surface - #55
Conversation
`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>
`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>
- `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>
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>
…face @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>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ba1705a115
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (hasActiveTaskExecution(task) && await source.isRunning()) return task | ||
| const result = await source.result() |
There was a problem hiding this comment.
Probe active runs even after the execution lease expires
When an execution owner crashes or misses heartbeats while its sandbox run continues, the task's fence is expired but session.result() still blocks until that run terminates. Because this guard calls isRunning() only for a live fence, recoverExpiredExecutionIfNeeded() reaches the blocking result() call during tasks/get, so a status poll can hang indefinitely for precisely the orphaned-run recovery case. Check the sandbox run state independently of lease freshness before awaiting the result.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 9ca2df3 and covered by a regression test.
You are right that the guard was one branch too narrow. recoverExpiredExecutionIfNeeded reached the blocking result() for exactly the orphaned-run case. Two changes:
reconcileTaskExecutionnow returns early wheneverisRunning()is true, with no lease-freshness condition, so no caller can reach a blockingresult()on a live run.recoverExpiredExecutionIfNeededprobes the run before reconciling and leaves the taskworkingwhen the sandbox still owns it. Without that second half the first half would have converted the hang into a wrongfulfailed, abandoning a live run.
tests/pr11-regressions.test.ts seeds an expired fence over an active run whose result() never resolves; tasks/get answers inside 1.5s, result() is never called, and the task stays working. Reverting the source change fails it with expected 'timeout' not to be 'timeout'.
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>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9ca2df3dde
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| isRunning: async () => (await session.runs()) | ||
| .some((info) => info.executionId === run.executionId && info.status === 'active'), |
There was a problem hiding this comment.
Recognize allowed nonterminal run statuses
After the 9ca2df3 fix, the fresh remaining issue is that the new public SandboxDurableSession.runs() contract permits any string status, but this probe recognizes only the literal active. A structurally valid custom sandbox returning a nonterminal value such as running or queued—values already treated as nonterminal sandbox-result states in task-execution-recovery.ts—will make isRunning() return false, after which tasks/get calls the documented-blocking session.result() and hangs until completion. Normalize all supported nonterminal statuses here or narrow the public status type so implementations cannot supply values the probe misclassifies.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 52995f7. Inverted the test rather than enumerating live states: 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, which is the safe direction for this probe. The contract is documented on SandboxDurableSession.runs() so a host implementor sees it.
The lapsed-lease regression test is now parameterized over active, running, queued and ACTIVE; reverting the source change fails three of the four.
`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>
|
@codex review |
|
Codex Review: Didn't find any major issues. Can't wait for the next one! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
tangletools
left a comment
There was a problem hiding this comment.
Approved after verifying the branch at 52995f7 locally with the absolute node path (v24.11.1) and pnpm 10.28.0.
Verified:
pnpm typecheckexit 0,pnpm test31 files / 532 tests passed exit 0,pnpm buildexit 0. Baseline at origin/main d2e45c7 was 525 tests.- Each of the four behavior fixes fails without its source change: tasks/get blocking (
expected 'timeout' not to be 'timeout'), the missing SSEid:cursor (expected undefined to be truthy), the question payload landing raw in a text part, and the lapsed-lease probe (3 of 4 parameterized cases). - src/sandbox-sdk-contract.ts is not a tsup entry and
grep -rl '@tangle-network/sandbox' dist/is empty, so the devDependency type import does not reach the published bundle. - Both Codex findings were fixed and answered in thread; the re-review of 52995f7 reports no major issues. Both required checks green on Node 22.13.0 and 24.18.0.
Post-merge review of #33 (the detached-execution fence), which merged on top of the failed-execution receipts work with no approval on record. I read the merged diff adversarially first, fixed what it turned up, then simplified the area and upgraded the first-party SDKs.
Five disjoint commits, one concern each.
Residuals cleared
tasks/getblocked for the whole agent turn.session.result()awaits a terminal result. #33 madehandleTasksGetcallreconcileTask(task, slug, allowActive = true)unconditionally, so a status poll fell through toresult()and held the request open until the detached run finished — the exact coupling the fence had just removed from the stream path. Proven before fixing: atasks/getagainst an in-flight run never returned inside 1.5s.The durable-session contract now carries
runs(), and reconciliation skipsresult()while the fence is live and the run is still active. An expired fence still awaits the result, because no owner is left to settle the task.The resume cursor was write-only. #33 added
TaskIdParams.lastEventIdandSandboxStreamEvent.idand passed the former toevents({ since }), but the resubscribe stream never wrote an SSEid:field — a client had no way to learn the cursor it was being asked to send back, and nothing tested it. Every artifact-update frame now carriesid:, skipping ids containing a line break so a runtime value cannot forge a frame boundary.SandboxPromptResult.questionhad the wrong type. It declaredquestion?: string; the runtime returns{ questionId, questions }. A detached run that finished awaiting a question put that object straight onto the wire as a text part:Found by the new compile-time SDK check (below), not by reading. Fixed by modelling the payload and reading a prompt out of it.
The pinned SDK.
@tangle-network/sandboxwas pinned at 0.37.0 as a devDependency that nothing imported, so the surface #33 adopted was never checked against it at all. See "Upgraded".Simplified
That commit on its own is 52 insertions / 81 deletions (−29).
dispatchDetachedSandboxStreamRichrestated all thirteen parameters ofdispatchSandboxStreamRichpurely to forward them, then guarded at run time against anoptionsargument both call sites always pass (the guard was unreachable, andoptionscould not be made required while trailing an optional parameter). It now forwards the base function's own argument tuple with the sandbox factory swapped, and takesoptionsas its first, required argument. A change to the base signature can no longer drift out of sync here.gatewayExecutionRecoverywas one dead constant inhandler.tsplus two hardcoded literals. One exported constant, two users.exactReferencenormalized the run control ref twice and carried a second failure branch that only changed an error message.withTaskExecution,clearTaskExecutionandhasExpiredTaskExecutioneach detoured through a nested conditional spread, an IIFE, or a second marker read.hasDetachedSandboxhad no caller outside its module.a2a.test.tsanda2a-long-horizon.test.tseach carried their own copy of the same agent, stub sandbox, demo key verifier, API key header, message builder and SSE parser — and the copies had already drifted, onetextMessagetaking positional arguments and the other an options object. Onetests/a2a-harness.ts, one shape: −180 lines across the two suites for +85 shared.Upgraded
@tangle-network/sandbox@tangle-network/agent-app@tangle-network/agent-interface@tangle-network/agent-runtimeAll at
npm view <pkg> versionlatest.src/sandbox-sdk-contract.tsis a compile-time-only module — nothing imports it, and it is not a tsup entry, so it never reachesdist/— asserting what the gateway actually reads out of the SDK: the dispatch result, the session result, the run listing and the interrupt outcome, eachsatisfiesthe gateway's own type. Event payloads are deliberately excluded:SandboxStreamEventis a narrow read of an intentionally open event bag, not a mirror of it. An SDK that reshapes the adopted surface now failspnpm typecheckinstead of passing silently. It earned its keep immediately by finding thequestiondefect above.Public type changes and their consumers
Both are on
SandboxBox, the structural type a host implements to supply its own sandbox. In this repo the only implementors are the test fixtures; external implementors of the detached path (added in #33, unreleased in a tagged version) need:session(id)must now also returnruns(): Promise<Array<{ executionId: string; status: string }>>. The gateway validates it alongsideevents/result/interruptand fails closed withA2A task execution session controls are unavailable.SandboxPromptResult.questionis now{ questionId: string; questions?: unknown }rather thanstring.No behavioral change to any public API.
Not done and why
agent-app@0.48.3declares peers forsandbox0.40.x,agent-runtime0.231.x andagent-eval0.182.x, so "newest of everything" is internally inconsistent upstream and pnpm reports three unmet peers. Typecheck, the full suite and the build are green on the newest set, andagent-appis a devDependency exercised by a single composition test, so I kept the newest versions rather than pinning three packages backwards to satisfy a range nothing here depends on.tsconfig.json'sinclude.pnpm typecheckcoverssrconly, so test type errors are invisible today. Addingtestssurfaces 9 pre-existing type errors across 4 files unrelated to this work; fixing them is its own net-additive change. This is why the SDK conformance check lives insrc/— intests/it would never have run.Verification
Same commands before and after,
/Users/drew/.nvm/versions/node/v24.11.1/bin/node, pnpm 10.28.0.Before, at
d2e45c7(origin/main):After:
Both new correctness fixes were checked against their own absence:
tasks/get: failed withexpected 'timeout' not to be 'timeout'before the fix.expected undefined to be truthywith theid:field suppressed.Line delta excluding the lockfile: +443 / −273. Runtime source, excluding the compile-time-only contract module, is +89 / −88; the rest is tests and the conformance check. The simplification commit alone is −29.
🤖 Generated with Claude Code