fix(workflow): harden terminal cleanup races

Queue worker results before settlement cleanup, claim terminal and death boundaries before provider callbacks, and close late-message admission.

Make child cancellation and disposal reentrancy-safe across the workflow bridge and generic subagent wrapper, with adversarial regression coverage and RFC documentation.
This commit is contained in:
Tianyi Cui
2026-07-12 10:17:31 +08:00
parent c5b1a7941f
commit 9fc2260bb6
10 files changed
+803 -98

No files matched your search

@@ -83,7 +83,9 @@ function defaultLabel(prompt: string): string {
/**
* One live script execution inside the worker. Constructed per run by the
* session; `drive()` is called exactly once and NEVER rejects — every failure
* becomes a {@link WorkflowResult} with a non-`completed` stop reason.
* becomes a {@link WorkflowResult} with a non-`completed` stop reason. After
* the session publishes that result it calls {@link reapAfterResult} exactly
* once to cancel any dropped child work without racing terminal publication.
*/
export class WorkflowExecution {
/** 1-based count of `agent()` calls started (the `agentsStarted` result field). */
@@ -171,7 +173,8 @@ export class WorkflowExecution {
* worker. Idempotent; the first reason wins.
* @param reason - human-readable cause, carried on the CANCELLED error and
* into child cancel RPCs. Required: every caller (the session's cancel
* message, drive()'s settle-reap) has a concrete reason.
* message and its post-result {@link reapAfterResult} call) has a concrete
* reason.
*/
cancel(reason: string): void {
if (this.cancelReason !== undefined) return
@@ -185,8 +188,9 @@ export class WorkflowExecution {
* Run the script to settlement. Resolves — never rejects — with the run's
* {@link WorkflowResult}: the materialized return value on `completed`, the
* failure message on `error`, and `cancelled` when the script died of
* cancellation. After settlement, any stray children a script fired without
* awaiting are cancelled (their `agent()` wrappers dispose them via RPC).
* cancellation. This method only chooses the result; the session must publish
* it and then call {@link reapAfterResult}, so the terminal message precedes
* settlement-only child cancellation on the worker-to-host FIFO channel.
* @returns the settled outcome — this promise NEVER rejects (the seam's
* `result`-never-rejects contract); every failure maps to a variant.
*/
@@ -214,15 +218,19 @@ export class WorkflowExecution {
// cannot throw — drive() resolving is the `result` never-rejects seam
// contract.
return { value: null, stopReason: 'error', error: renderThrown(error), agentsStarted: this.started }
} finally {
// Reap strays: a script that fired agent() calls without awaiting them
// leaves live children behind after settlement — cancel them all. (The
// per-call wrappers dispose each child; the contain() consumer keeps
// their rejections from going unhandled.)
if (this.cancelReason === undefined) this.cancel('workflow settled')
}
}
/**
* Reap strays only after the caller publishes the chosen terminal result.
* Aborting the controller synchronously sends child-cancel RPCs, so calling
* this before publication would let a provider callback reenter host
* cancellation and misclassify a result the script had already chosen.
*/
reapAfterResult(): void {
if (this.cancelReason === undefined) this.cancel('workflow settled')
}
/**
* Attach a no-op rejection consumer WITHOUT changing what the caller
* receives: if the script drops the promise (no await), cancellation cannot