Skip to content

D: abortable clone and storage preparation in process groups (#51 item 5) - #73

Merged
mchwang merged 12 commits into
mainfrom
feat/d-abortable-preparation
Sep 28, 2026
Merged

mchwang merged 12 commits into
mainfrom
feat/d-abortable-preparation

Conversation

@mchwang

@mchwang mchwang commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

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.
    • It reports { pgid, startedAt } through onProcessGroup synchronously, in the same turn as the spawn, so F can save preparation_pgid and preparation_started_at before anything else runs.
    • On abort, at the deadline, or when output exceeds its limit, the group gets SIGTERM, then SIGKILL after 5 s.
    • It settles only after the leader has exited and every other member of its group is gone. A child left behind in the group is killed even after a normal exit.
    • It never rejects. The outcome has the same shape as runDocker's, so createOutcomeUnknown classifies it.
  • createTaskCloneAsync (git/clone.ts) takes signal and onProcessGroup. 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.
    • An abort stops the running call.
    • The allocation then removes what it created. That cleanup is not cancelled, and its calls are reported through onProcessGroup too.
    • A create cut short by the abort counts as possibly created.

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 execFileSync or spawnSync, so their API, behaviour and error messages are unchanged for Ask and the existing tests. The async variants run each step through runInProcessGroup. 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.ts covers 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.ts covers a normal clone, and an abort while a fake git clone ignores 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.ts covers 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 while docker start ignores 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 test job failed on 5b1d1cc and 4fb2c41 because the walk-pause test counted reads across an awaited Git call and hit 1,001 on Linux; d0cff55 fixed 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

Round Reviewer Findings Fixed in
1 /engineering:code-review 1–5 357e149
2 /code-review 6–7 c913259
3 /engineering:code-review 8–10 4728f26
4 Copilot 11–13 5b1d1cc
5 /code-review 14–15 523992a
6 self-review, pass 2 16 ecb2fb3
7 self-review, pass 3 17 5fa6c18
8 independent review agent 18–19 2f45273
9 second independent review agent 20–21 bd956a6
10 third independent review agent none —

Nothing 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).

# Finding Classification
1 The real-Docker test checked group liveness with kill -0 -<pgid>, which the Linux kill utility reads as an option, so the assertion always failed. One-off: a test-tooling quirk. The fix uses process.kill(-pgid, 0), as the other tests already did.
2 A throwing onProcessGroup rejected the promise and left the child running, unwatched. New rule (RR): a synchronous caller hook inside a lifecycle throws, blocks past the deadline, or aborts the signal.
3 signalGroup rethrew EPERM from timers and listeners, which could crash the runner or hang the drain. New rule (RD): verify every behavioural claim the change makes (here, "never rejects") on every path, with a test that fails if it is false.
4 An abort during the allocation-ID check reported a generic message instead of "cancelled". One-off: diagnostic wording. The behaviour was already fail-closed.
5 No tests for 2 and 3. Covered (RR): "Every reproduced race requires a failing-before and passing-after regression."
6 An abort raised inside onProcessGroup came before the listener existed and was never seen. New rule (RR): a signal already aborted when its listener is attached must still take effect.
7 A descendant that left the group but kept the output pipe stopped close from ever firing, so the call never settled. New rule (RR): a child process exits, but a descendant outside its process group still holds its output pipes.
8 The SIGKILL grace timer was cleared only after the pipe wait, so it could fire at a group ID the group no longer held. One-off: timer ordering in this helper; the default 5 s grace period made it unreachable outside tests.
9 The pipe-wait timer kept a finished process alive for up to 1 s. One-off: timer hygiene.
10 The doc's "settles only after exit" did not state the EGROUPALIVE and 1 s pipe bounds. New rule (RD): verify every behavioural claim in docs as well as code.
11 The storage link walk paused only per path taken off the queue, not per entry read, so one huge directory held the event loop. New rules (RD): reread every changed function in full in each pass (the walk was unchanged since the first commit), and verify the "pauses every 1,000 entries" claim through nested loops.
12 The deadline was armed only after onProcessGroup returned, so a slow durable write extended the child's run. New rule (RR): timers and listeners that bound a lifecycle must already be armed when a synchronous hook runs.
13 The same pause gap in the clone's object audit. Same as 11.

| 14 | spawn throws synchronously for errors such as ENOMEM or E2BIG, which rejected runInProcessGroup and 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 "status is a number only when the command ran to completion" was false on this path. It now settles as ESTDIOHELD. |
| 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 "status is a number only when the command ran to completion" was false on this path too. It now settles as ENOBUFS. |
| 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 timeoutMs above 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-review at high effort or above) for concurrency, process and cleanup code.

🤖 Generated with Claude Code

#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>
Copilot AI lite review requested due to automatic review settings September 28, 2026 20:13
mchwang and others added 3 commits September 28, 2026 13:25
…; 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved cancellation, event-loop responsiveness, timeout, and cleanup coverage issues remain.

Review effort: Lite
Findings: 3 Medium severity

Open (3)

Comment thread agents/container/storage.ts Outdated
Comment thread agents/process-group.ts Outdated
Comment thread git/clone.ts Outdated
mchwang and others added 8 commits September 28, 2026 15:48
…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>
@mchwang
mchwang merged commit 9854fe3 into main Sep 28, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants