feat(sdk-ts): pause/WAITING + async-execution dispatch (parity with Python) — closes #726#731
Conversation
…th Python) Ports the control-plane pause/resume mechanism to the TypeScript SDK (closes the gap tracked in #726, where it existed only in the Python SDK). Three layers: 1. Async-execution dispatch (on by default, `asyncExecution` config to opt out): a reasoner dispatched by the control plane (carrying `X-Execution-ID`) is acknowledged immediately with `202 Accepted` and run detached; its terminal status is delivered out-of-band via `POST /executions/{id}/status`. This frees the dispatch connection so a reasoner can wait far longer than the control plane's synchronous dispatch ceiling. A watchdog (pause-aware active-time budget) guarantees a terminal status even if a reasoner hangs. 2. Pause primitive: `ctx.pause()` / `Agent.pause()` transition the execution to WAITING via request-approval and block on a promise resolved by the always-on `POST /webhooks/approval` route when the control plane delivers the decision. Returns an `ApprovalResult`; times out to `{ decision: 'expired' }` rather than throwing. Backed by a PauseManager + PauseClock (new src/agent/pause.ts). 3. Multi-hop propagation: the remote `call()` path now submits async and polls for the result, and when an awaited child enters WAITING it pushes the caller's own execution to WAITING via `notifyAwaiterStatus` — so ancestors don't time out while a descendant legitimately waits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- pause.test.ts: PauseClock accounting, PauseManager resolve/fallback/cancelAll, ApprovalResult getters, and the /webhooks/approval route. - agent_async_execution.test.ts: 202 fast-ack + out-of-band succeeded/failed reporting, non-object result wrapping, sync fallback (no header / asyncExecution:false), and the reasoner_timeout watchdog. - agent_pause.test.ts: end-to-end ctx.pause() resume via webhook, expired timeout, request-approval failure, and the awaiter-status multi-hop cascade, all driven through a mock control plane. - agentfield_client_async.test.ts: executeAsync / getExecutionStatus / waitForExecutionResult (incl. WAITING-window clock pause) / notifyAwaiterStatus / reportExecutionResult. - agent_runtime_paths.test.ts: pin the existing remote-delegation test to the synchronous path (asyncExecution:false); the async default is covered above. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds `planWithPause`, which uses the high-level `ctx.pause()` primitive alongside the existing low-level ApprovalClient demo, so the example shows both the parity API and the manual polling approach. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Performance
⚠ Regression detected:
|
Follow-up: verified the exact ceiling scenario from the issue (reproduce + fix)Compressed the control plane's Part A — reproduce the bug (sync dispatch, The call dies at the 8s ceiling — exactly the failure mode described in the issue. Part B — prove the fix (async dispatch default + So the pause genuinely outlives the dispatch ceiling and resumes to a terminal |
📊 Coverage gateThresholds from
✅ Gate passedNo surface regressed past the allowed threshold and the aggregate stayed above the floor. |
📐 Patch coverage gateThreshold: 80% on lines this PR touches vs
✅ Patch gate passedEvery surface whose lines were touched by this PR has patch coverage at or above the threshold. |
…Run-ID)
The functional test `test_ts_agent` invokes the reasoner via the legacy
synchronous endpoint `POST /api/v1/reasoners/{node}.{reasoner}`, which forwards
the agent's HTTP response verbatim and cannot handle a 202. With async dispatch
gated only on `X-Execution-ID`, the agent 202-acked there and the caller got the
`{status:"processing"}` marker instead of the result.
Gate async dispatch on BOTH `X-Execution-ID` and `X-Run-ID`. `X-Run-ID` is set
only by the control plane's async-aware `callAgent` path (workflow execute,
execute/async, agent-to-agent calls, triggers) — all of which wait for the
out-of-band `/status` result. The legacy sync invoke endpoint omits `X-Run-ID`
for long-running agents, so the agent now runs synchronously and returns the
result inline there, while pause/async continues to work on the execute paths.
Verified live: the legacy endpoint returns the inline echo result again, and a
pause submitted via execute/async still reaches WAITING and resumes to
succeeded. Adds a regression test for the X-Execution-ID-without-X-Run-ID case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI fix: async dispatch now gated to async-aware paths (functional tests green)The functional job caught a real regression from async-on-by-default. Root cause: I gated async dispatch on Fix: gate async dispatch on both Verified live against a local control plane:
Added a regression test for the |
Summary
Closes #726. The control-plane pause/resume mechanism (
pause(),WAITINGstatus, pause-aware timeout accounting, multi-hop propagation) previously existed only in the Python SDK. This ports it to the TypeScript SDK.The investigation surfaced that
ctx.pause()alone is not enough: the TS SDK held the HTTP dispatch connection open for the entire reasoner, so a long pause would die at the control plane's synchronous dispatch ceiling. Python avoids this by fast-acking dispatch with202 Acceptedand reporting the result out-of-band. So this PR ports three layers:1. Async-execution dispatch (on by default;
asyncExecution: falseto opt out)A reasoner dispatched by the control plane (carrying
X-Execution-ID) is acknowledged immediately with202 { status: "processing", execution_id }and run detached; its terminal status is delivered viaPOST /executions/{id}/status. A pause-aware active-time watchdog (executionBudgetMs, default 2h) guarantees a terminal status even if a reasoner hangs. Requests without the header (direct callers / tests) keep the synchronous request/response contract.2. Pause primitive —
ctx.pause()/Agent.pause()Transitions the execution to
WAITINGvia request-approval, then blocks on a promise resolved by the always-onPOST /webhooks/approvalroute when the control plane delivers the decision. Returns anApprovalResult; times out to{ decision: 'expired' }rather than throwing. Backed by a newPauseManager+PauseClock(src/agent/pause.ts), mirroring the Python_PauseManager/PauseClock.3. Multi-hop propagation
The remote
call()path now submits async and polls for the result; when an awaited child entersWAITING, the caller pushes its own execution toWAITINGvianotifyAwaiterStatus(awaiter-statusendpoint) — so ancestors don't time out while a descendant legitimately waits.The control-plane side was already fully language-agnostic (202 handling,
/status,request-approval,awaiter-status, approval-response webhook + CP→agent callback); the entire change is in the TS SDK.Behavioural note (reviewers please weigh in)
Async dispatch defaults on for full Python parity — this changes the dispatch behaviour for existing TS agents (202-ack + out-of-band result instead of a held connection). The control plane already handles both paths identically and Python has run this way in production.
asyncExecution: falserestores the legacy synchronous behaviour. Terminal results are wrapped so a reasoner returning a non-object still delivers cleanly.Testing
tsc --noEmit) clean. Ran the exact CI gates (npm ci/npm run lint/npm test) on Node 20.202, the execution reacheswaiting / waiting_for_approval— proving the dispatch connection was not held.approval-response→ CP transitions to running and calls back the agent → reasoner resumes → terminalsucceededwith the approval result.waiting / awaiting_childwhile the worker was paused (awaiter-statusobserved server-side), then resumed tosucceededwith the child's result nested.API additions
ctx.pause(opts)/agent.pause(opts)→Promise<ApprovalResult>ApprovalResult,PauseManager,PauseClockexported from the package rootAgentConfig.asyncExecution?: boolean(defaulttrue),AgentConfig.executionBudgetMs?: numberAgentFieldClient:executeAsync,getExecutionStatus,waitForExecutionResult,notifyAwaiterStatus,reportExecutionResult🤖 Generated with Claude Code