workflow: re-check cancellation after the slot acquire

Codex convergence round 1 on the review-response commits: agent()'s
post-acquire window was real and unguarded. `await acquireSlot()` yields
at least one microtask tick even when a slot is free (and a queued
waiter resumes a tick after its release), so a cancel() landing in that
tick let the continuation start a child carrying an ALREADY-aborted
signal — the in-code comment claimed the window could not exist. A
provider that subscribes only to future abort events (the test stub;
the seam does not promise pre-aborted-signal handling) would never
settle such a child, leaking it until the dispose grace abandoned the
run, and a backend that misses the pre-aborted signal would burn a full
model turn after the user cancelled.

agent() now re-checks isCancelled() immediately after the acquire
(inside the slot-owning try, so the finally still releases), making
every post-cancel path reject before subagents.start. New deterministic
regression: cancel() in the same synchronous frame as start() lands in
the free-slot await tick — the run settles cancelled with ZERO children
started (previously: one leaked child and a grace-delayed settle). The
raced-release test's comment now states what it actually pins (the
queued-waiter rejection path). Also aligns the RFC's auto-concurrency
formula with the code (min(16, max(1, availableParallelism() - 2))).
This commit is contained in:
Tianyi Cui
2026-07-06 01:39:01 +08:00
parent 2ba4964aba
commit 706691a7df
3 files changed
+27 -7

No files matched your search

+6 -4
View File
@@ -375,10 +375,12 @@ export class WorkflowExecution {
await this.acquireSlot()
try {
// No cancelled re-check here: a cancel cannot interleave between a
// waiter's resolution and this continuation (single-threaded, no await
// between them), and a child started moments after a cancel still dies
// via the shared abort signal — the CANCELLED mapping below covers it.
// Re-check after the acquire: the await yields at least one microtask
// tick even when a slot is free, and a queued waiter resumes a tick
// after its release — a cancel() landing in either window must not
// start a child (it would carry an ALREADY-aborted signal, which a
// provider subscribing only to future abort events would never see).
if (this.isCancelled()) throw this.cancelledError()
let run
try {
run = this.ctx.subagents.start(this.limits.provider, {