Repository navigation
D: abortable clone and storage preparation in process groups (#51 item 5) - #73
Merged
Merged
Conversation
#51 item 5) createTaskCloneAsync and prepareTaskFilesystemsAsync take an AbortSignal and run every Git or Docker call as the leader of its own process group (runInProcessGroup in agents/process-group.ts). Each group is reported through onProcessGroup in the same turn as the spawn, so F can save it before anything else runs. On abort or at the deadline the group gets SIGTERM, then SIGKILL after 5 s, and the call settles only once the leader and every other member of its group have exited. An aborted clone removes its partial directory; an aborted allocation removes what it created with uncancelled cleanup. The clone and allocation are now step generators shared by both variants: the synchronous helpers keep their API and behaviour, and long file walks pause regularly so the asynchronous variants keep the event loop free. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…; report early aborts as cancelled runInProcessGroup ran onProcessGroup unguarded, so a throwing callback left the child running and unwatched. It now kills the group at once and settles only after it exits, with EUNRECORDED and the callback's error as the cause. signalGroup no longer throws (only ESRCH means gone), so a group member that cannot be signalled cannot crash the runner from a timer or hang the drain. An allocation ID check whose client was stopped or never started now reports why (for example cancelled) instead of a generic message. The real-Docker abort test checks groups with process.kill, which the kill utility cannot stand in for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xit, not its pipes An abort raised inside the onProcessGroup callback came before the listener existed and was never seen, so the child ran to its deadline. The helper now checks the signal right after attaching the listener. Settlement waited for 'close', which also needs every holder of the output pipes; a process that left the group with setsid but kept the pipe survived the group kill and hung the call forever. It now settles from the leader's exit: drain the group, wait up to 1 s for the pipes, then destroy them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t; document settlement bounds The grace-period SIGKILL timer was cleared only after the up-to-1-second pipe wait, so a sub-second grace could fire it at an ID the group no longer held. It is now cleared as soon as the drain ends. The pipe-wait timer is cleared when the pipes close, so a finished call never keeps the process alive. The lifecycle doc now states the EGROUPALIVE and 1-second pipe bounds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…group is recorded The object audit and link check counted only paths taken off the queue, so reading one huge directory held the event loop (and an abort) for all of it. They now count every entry read too, and pause every 1,000 either way. The process deadline was armed only after onProcessGroup returned, so a slow durable write let the child run past timeoutMs; it is now armed at the spawn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ms, independent review Copilot found three issues on #73 that several author self-reviews missed: a pause cadence claimed in comments but broken by an inner directory loop, and a deadline armed only after a synchronous callback. New review-readiness rules require rereading every changed function in each pass, verifying every behavioural claim with a failing-if-false test, and an independent review for concurrency, process and cleanup code. Two new required race cases cover synchronous lifecycle hooks and descendants that hold output pipes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The two object checks run before and after the clone call, which is awaited and so yields the event loop. The test counted reads across that boundary as one run and failed at 1,001 on Linux's object layout. It now resets the run at each Git spawn; it still fails at 2,506 on the pre-fix clone code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…leader has exited spawn throws, rather than emitting 'error', for spawn failures such as ENOMEM or E2BIG. The throw rejected runInProcessGroup, so the step drivers skipped the generators' cleanup: an allocation left its volumes, stayed live and kept its claim. It is now reported as an outcome. stop() no longer runs after the leader exits, so output read during the pipe wait cannot signal a drained, possibly reused group ID or re-arm the SIGKILL timer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a process outside the group held the pipes past the 1 s wait, the call returned the leader's exit status with output that may be incomplete. It now settles with ESTDIOHELD and keeps what was read. Before deciding, it gives I/O one more event-loop turn, since after a long block the timer can fire before output that is already waiting is read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ounds Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s a cancel Output crossing the limit after the leader exited was dropped while the call still reported success; any crossing now settles as ENOBUFS. An abort landing between two Git or Docker calls made the next call refuse to start, so the clone or allocation rejected with a plain error instead of an AbortError; both now rethrow any failure after the signal aborted as an AbortError with the original as its cause (an unsettled cleanup stays an AggregateError). Found by an independent review agent after the author's passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt tests deterministic setTimeout treats delays above 2^31-1 ms as 1 ms, so a larger timeoutMs made the async allocation time out at once while the sync one worked. Both now refuse it up front, and runInProcessGroup reports it as not started. The between-calls abort tests aborted one turn after a child's exit, which in about a quarter of runs landed during the next call instead. They now wrap runInProcessGroup and abort after one call settles and before the next is requested, and assert that exactly one call was refused before it started. Found by a second independent review agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Sep 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Item 5 of #51, the last item required before F1 merges.
What it adds
runInProcessGroup(agents/process-group.ts) runs one command as the leader of a new process group.{ pgid, startedAt }throughonProcessGroupsynchronously, in the same turn as the spawn, so F can savepreparation_pgidandpreparation_started_atbefore anything else runs.SIGTERM, thenSIGKILLafter 5 s.runDocker's, socreateOutcomeUnknownclassifies it.createTaskCloneAsync(git/clone.ts) takessignalandonProcessGroup. Every Git call runs in its own group, and an abort removes the partial clone before the promise rejects.prepareTaskFilesystemsAsync(agents/container/storage.ts) works the same way for Docker calls.onProcessGrouptoo.Design
The clone and the allocation are now step generators that yield each Git or Docker call. The existing synchronous helpers run the same steps with
execFileSyncorspawnSync, so their API, behaviour and error messages are unchanged for Ask and the existing tests. The async variants run each step throughrunInProcessGroup. The long file walks (the object audit and the link check) pause every 1,000 entries, so the async variants keep the event loop free, and an abort takes effect at the next pause.Tests
test/agent-process-group.test.tscovers the group being reported in the same turn as the spawn, killing leftovers after a normal exit, SIGKILL after the grace period for a group that ignores SIGTERM, abort, the output limit, and "not started" cases. A mutation that skips the leftover kill fails it.test/agent-clone-async.test.tscovers a normal clone, and an abort while a fakegit cloneignores SIGTERM and runs a background child. The clone settles only after the SIGKILL, the event loop keeps ticking, nothing in the group survives, and the partial clone is removed.test/agent-storage-async.test.tscovers the same two cases for storage, against a fake Docker CLI, so they run without a daemon.test/agent-container.test.ts(real Docker) aborts an allocation whiledocker startignores SIGTERM. It checks that nothing labelled with the allocation remains and that no group survives.Non-Docker suite: 686 passed locally on the latest code change. CI's
testjob failed on5b1d1ccand4fb2c41because the walk-pause test counted reads across an awaited Git call and hit 1,001 on Linux;d0cff55fixed the test, which still fails at 2,506 on the pre-fix clone code. The exact-head CI result is the one to use.Review rounds
/engineering:code-review357e149/code-reviewc913259/engineering:code-review4728f265b1d1cc/code-review523992aecb2fb35fa6c182f45273bd956a6Nothing was declined. Every behaviour fix has a test that fails on the code before it (confirmed by running the new test against the previous version), except 8, 15 and 18, where the operating system would have to reuse a group ID within a sub-second window, and the extra I/O turn in 16, whose event-loop ordering a test could not reproduce (the attempted test passed on the old code, so it was dropped). The fail-closed part of 16 is tested.
Review-lesson audit
Rules cited from
AGENTS.md: RR = "Required race regressions", RD = "Review readiness". The new rules are in this branch (4fb2c41).kill -0 -<pgid>, which the Linuxkillutility reads as an option, so the assertion always failed.process.kill(-pgid, 0), as the other tests already did.onProcessGrouprejected the promise and left the child running, unwatched.signalGrouprethrewEPERMfrom timers and listeners, which could crash the runner or hang the drain.onProcessGroupcame before the listener existed and was never seen.closefrom ever firing, so the call never settled.EGROUPALIVEand 1 s pipe bounds.onProcessGroupreturned, so a slow durable write extended the child's run.| 14 |
spawnthrows synchronously for errors such asENOMEMorE2BIG, which rejectedrunInProcessGroupand made the step drivers skip cleanup: an allocation kept its volumes, stayed live and kept its claim. | New rule (RD): verify every stated claim ("never rejects") on every path. || 15 |
stop()could still run after the leader exited, sending signals to a drained, possibly reused group ID and re-arming the SIGKILL timer. | New rule (RD): the claim that nothing is signalled once the group is gone, checked through every late event. || 16 | A pipe held open past the 1 s wait (or a timer that fired before waiting output was read) returned the leader's exit status with output that may be incomplete. | New rule (RD): the claim "
statusis a number only when the command ran to completion" was false on this path. It now settles asESTDIOHELD. || 17 | The lifecycle doc did not say that a held pipe settles as
ESTDIOHELD, or that a failed spawn settles as not started. | New rule (RD): verify every behavioural claim in docs as well as code. || 18 | Output crossing the limit after the leader exited was dropped silently, and the call still reported success. | New rule (RD): the claim "
statusis a number only when the command ran to completion" was false on this path too. It now settles asENOBUFS. || 19 | An abort landing between two calls made the next call refuse to start, so the clone or allocation rejected with a plain error rather than an
AbortError. | New rule (RR): a signal already aborted when its listener is attached must still take effect, here across calls as well as within one. || 20 | A
timeoutMsabove 2³¹−1 ms passed validation, but Node timers treat it as 1 ms, so the async allocation timed out at once while the sync one worked. | New rule (RD): verify the claim that both variants "run these same steps" with the same behaviour, on every input. || 21 | The between-calls abort tests often aborted during the next call instead, so they did not reliably test the path they named. | New rule (RD): each claim needs a test that fails if the claim is false; the test must reach the path it names. |
Findings 11–13 came from Copilot after three author-run self-reviews had passed. That gap is the reason for the RD rule requiring an independent review (
/codex review, a separate review agent, or/code-reviewathigheffort or above) for concurrency, process and cleanup code.🤖 Generated with Claude Code