Repository navigation
fix(supervisor): retry stalled Claude source reads and remove helpers Docker creates late - #867
Merged
Merged
Conversation
… exchange The cancel-private-exchange case dropped the helper after a fixed 50 ms. Under load that landed before the helper reached its exchange, sometimes in the middle of a request, and the fake daemon then panicked on the abandoned connection. The fake now signals when the helper reaches its exchange and the test drops the helper at that point; the fake also ignores a request its caller abandoned, as Docker does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eated helpers
A Docker container create can take tens of seconds on a busy host: Docker
mounts and unmounts the new container's overlay root, and the kernel flushes
the whole filesystem under Docker's data root when that mount goes away, so
the create waits for every unwritten change on the disk (here: ~0.1s idle,
4.5s with 1 GB unwritten, 16.7s with 3 GB; up to 31s in busy parity runs).
One such create used up the Claude source reader's 25s limit, failing a
freshclaude create. The abandoned create still completed, leaving the helper
container and the candidate scratch volume behind.
Helper lifecycle (backend/helper_lifecycle.rs):
- A helper create runs as its own task, so a caller that stops waiting no
longer abandons it. Cleanup waits for Docker's answer, then removes the
helper by exact name. A create still unanswered when the caller's window
ends goes to a background reaper that removes the helper (and, for a
candidate read, the scratch volume after it). This covers the source
reader, the candidate reader and its scratch volume, and the mountpoint
helper on both the candidate and launch paths.
- The Claude source reader retries a run whose create or start timed out, or
that Docker refused with a server error or left unanswered. Each attempt
keeps the 25s bound; the read gets 60s in all (two full attempts with
their 5s cleanup), waits 1s then 2s, and starts a retry only after the
previous attempt's helpers are gone. Every other failure stays final.
- The supervisor's observation bound becomes 65s (the retry plus reply
slack), so the Claude recovery callback bound becomes 165s.
- Lifecycle JSONL events: supervisor.helper.retry (warn),
supervisor.helper.retry_exhausted (error), supervisor.helper.recovered,
supervisor.helper.late_create.{deferred,removed,unconfirmed}.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ker create The test Docker proxy can hold the next creates of a short-lived helper (Claude source reader, candidate reader or mountpoint helper) before forwarding them, so Docker makes the helper after its caller's deadline, as a create stalled behind a host-wide flush does. The managed-runtime harness enables it from FRESHELL_TEST_BROKER_DELAY_HELPER_CREATE (<prefix>:<delayMs>[:<count>]) and records the setting in its lifecycle log; each held create is recorded as an inject_failure broker event. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le mid-scan The fresh-agent parity secret check lists the supervisor's runtime.sqlite3* files and then reads each one. SQLite can delete its -wal file between the two steps after a checkpoint, which failed a parity run with ENOENT. A file that vanishes now restarts the whole scan, so every byte is still checked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…place The helper container and the candidate scratch volume each matched a late create answer to decide whether a name conflict made the name foreign. Both now use InFlightCreate::settle_ownership, and the mountpoint helper's contract (what `owned` holds on return) is documented. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A helper create that Docker answers after 5s or more is now recorded as a supervisor.helper.create_slow warn event with the helper's name, the latency and the answer's status. The create task logs it when the answer arrives, so a create answered after its caller gave up is reported too. Until now a stalled create was visible only by instrumenting the test Docker proxy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ker stall The first qualification run on the retry branch failed the same way the parity suite used to: one source-reader create waited 43s for the host to flush its disk (up to 8 GB sat unwritten while the harness built release binaries), and a 60s read left the retry under its 10s minimum once Docker answered. The read now gets three attempt lengths, each with its 5s cleanup (90s): the attempt a stall times out, up to one more attempt length for Docker to answer that create, and one full retry. Each attempt keeps its 25s bound. The supervisor's observation bound follows (95s), and with it the Claude recovery callback's (225s). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y tests The terminal parity test had its own copy of the supervisor registry scan and failed a qualification run the same way: SQLite deleted runtime.sqlite3-shm between the listing and the read. Both parity tests now run supervisorRegistrySecretScan, which restarts the whole scan when a file vanishes, and a unit test runs the scan against a registry whose -wal or -shm file disappears after the listing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…removes them A parity run failed a launch-path mountpoint helper whose create Docker answered 43s later. The supervisor handed the helper to its background reaper, but the test then failed and cleanup force-removed the supervisor container, reaper included, before Docker answered. The helper was created afterwards and left behind, and the soul's provider volume stayed attached to it. The test Docker proxy now tracks every request it accepted and can wait until each one has its Docker answer recorded (settleForwarded). Cleanup waits up to 90s for that after removing the supervisor, so its removal of the helpers the proxy recorded includes a helper created that late. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…leanup runs out of time
A helper removal that ran past its 5 s window, or failed, dropped the
attempt's objects without handing them anywhere. On the candidate path the
helpers are removed first, so a slow force-remove of the reader (the same
disk-flush stall this branch is about) left the
freshell-claude-candidate-<requestId> scratch volume behind. The launch
path handed off only a create Docker had not answered, so a launch
mountpoint helper whose removal timed out leaked too.
Objects now stay owned until their absence is confirmed: an unconfirmed
removal goes to the background reaper like an unanswered create does. The
reaper re-runs the exact-name, ownership-checked removal with a growing
wait for up to 120 s. A candidate read releases its mount skeletons as
soon as its helpers are gone, before the scratch volume, and skeleton
cleanup is now idempotent so a retried removal can resume. The launch
hand-off moves into run_launch_mountpoint_helper, which its test now runs
directly (removing the hand-off fails the late-create mode). Reaper events
are renamed supervisor.helper.cleanup.{deferred,removed,unconfirmed} and
carry the reason they were deferred.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ter the read gave up No test covered the candidate path's wait for a scratch-volume create Docker answers late: dropping that wait from CandidateObjects::settle passed every candidate and mountpoint test, and the volume Docker made afterwards would leak again. The new late-scratch-create mode answers the volume create after the reader's deadline and checks the volume is gone once Docker has made it; without the wait it fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eeps Four of the reader retry tests failed together at a load average of about 519 with the code they exercise unchanged: their attempt, settle and total windows were 150-250 ms, so scheduling delay decided whether the create was even sent before its deadline or whether a healthy synthetic read fit its attempt. The windows are now seconds long (3 s attempts, 2 s cleanup, 2 s minimum retry, late answers at 5-12 s) with the same assertions, and the bound on the whole read sits between its total and Docker's late answer. Four parallel copies pass at a load of about 330; dropping the reaper hand-off or making a timeout final still fails them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ery scope The reader retry raised the enclosing limits instead of fitting inside them: one observation went from 35 s to 95 s, so a Claude recovery's callback wait went from 105 s to 225 s, and a stop issued during a recovery could wait about 285 s. The callback wait is back to 105 s: its own 35 s plus two observations whose reader succeeds on its first attempt (35 s each). The supervisor now hands the backend the instant a read must end by: the reader's whole retry (90 s), or 5 s before the recovery scope ends, whichever is first. The retry keeps every attempt and its cleanup inside that instant, so a retry within a recovery uses only what is left of the callback it serves and never outlives it. A read whose scope would expire first could only be refused afterwards anyway. Reads outside a recovery keep the 90 s retry plus reply slack. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The retry log said `step: "operation"` for every timeout, so a create Docker stalled on could not be told apart from a reader that hung after starting. Each reader attempt (and the candidate read's scratch volume and mountpoint helper) now records its step, and the retry, exhaustion and refusal events name it: prepare, create, start, read, exit, verify, scratch_create or mountpoints_*. Timeouts stay retryable in every step, on evidence: in a qualification run a candidate reader's create and start answered at once, yet the attempt timed out while 2 GB of unwritten data drained, and Docker reported the reader's exit exactly when the drain finished; the retry recovered the read. Narrowing retries to create and start would have failed that run. A reader that truly hangs now fails after its bounded attempts with every event saying `read`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At a load of about 830 on this 96-core host the 3 s attempt windows still decided outcomes: a healthy synthetic retry missed its attempt and two reads overshot their bounds by seconds. Attempts are now 5 s (3 s cleanup, 3 s minimum retry), late answers come at 9-25 s, and the bounds on a whole read sit 8 s past its end and 7 s before Docker's answer. The synthetic daemon also tolerates a request the caller abandoned mid-way instead of failing the test. Four parallel copies pass at a load of about 440. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…upervisor left Harness cleanup waits for late helper creates and removes the late containers, but never removed freshell-claude-candidate-* scratch volumes. A supervisor removed mid-read, or before its background cleanup ran, leaves one behind: the same case as the leftover freshell-claude-candidate-22f7ba05... volume on the development host. The broker already tracks exactly which candidate volumes this run created. Cleanup now deletes each one, after every container that could mount it is gone, through the broker's proxied DELETE so the exact ownership check still applies, confirms its absence, and records it. A volume whose labels changed is refused and reported, not removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oad on helpers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In two qualification runs at a host load of 300-400 a reader attempt's removal took longer than the flat 5 s cleanup window: Docker held the container while it was exiting, so even the inspection waited about 10 s. The removal was handed to the background reaper, which confirmed it 3-5 s later, but the unconfirmed removal had already made the read final, although 25-55 s of the time the read had reserved for cleanup and retries remained. A removal now gets one cleanup window or, inside a retried read, every instant up to the read's end, whichever is later. The read still ends, its cleanup included, inside its total and before its caller's end; stand-alone helper runs keep the 5 s window. The candidate path's mountpoint helper uses the same deadline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves the mountpoint test against #866's frozen test clock: main's version of the joined preparation test is kept, adapted to this branch's helper API (HelperDeadlines, AttemptStep, owned HelperContainer). The launch hand-off modes (a create Docker answers late, a cleanup inspection that outruns its window) move to their own real-time test, because the background reaper's removal is real socket I/O after the run returns; removing the hand-off, or handing off only an unanswered create, still fails it. A stand-alone helper's removal keeps exactly its cleanup window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
danshapiro
added a commit
that referenced
this pull request
Oct 23, 2026
Brings in #864 (Windows/Linux tree-guard fixes and the shared process_probe test support), #866/#868/#869 (flaky-test fixes) and #867 (managed helper create retry) on top of core PR-C. No textual conflicts. Seven files changed on both sides and merged cleanly; each was checked by hand: - claude.rs: main's await_create_ownership_committed / await_create_adoption_committed helpers and their call sites land in PR-C's slimmed test module unchanged (12 uses, same as main). - claude_snapshot.rs: main's "never clear HOME" fix applies to the locate_transcript test; PR-C's genesis tests do not touch HOME. - claude/teardown_tests.rs, lib.rs: main's zombie-aware ProcessProbe module and the REST turn test-pause handshake sit beside PR-C's module reshuffle. - opencode_ws.rs, session_handoff/tests.rs: independent hunks. - fresh-agent-rollback-rust.spec.ts: main's wait for the codex undo refill before typing /redo applies inside PR-C's reworked spec. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Problem
The managed-provider parity suite (
test/integration/server/provider-parity-contract.test.ts) still failed about 2 runs in 6 after #861. Creating a freshclaude session failed because one Docker create for a short-lived helper container (freshell-claude-source-*,freshell-mountpoints-*) took 18–48 s and used up the Claude source reader's single 25 s attempt. A second problem: when such a slow create finished after the reader gave up, the helper container (and, on the candidate path, its scratch volume) was left behind.Cause
Docker mounts and then unmounts a new container's filesystem while it creates it. On this kernel the unmount flushes everything not yet written to the disk under Docker's data folder. During parity runs up to 8 GB sat unwritten (release builds, other agents), so whichever create came first after a burst of writes waited for the flush: 0.1 s on an idle disk, 4.5 s with 1 GB unwritten, 16.7 s with 3 GB, up to 48 s in the runs. The create right after it is fast again, which is why managed-session creates stayed quick. The same flush can also stall a container's start, the reader's own writes, and Docker's report that the reader exited.
The leak: on a timeout the create request was dropped, and cleanup looked the helper up by name before Docker had made it.
Fix (all in the supervisor's helper lifecycle,
crates/freshell-supervisor/src/backend/helper_lifecycle.rs)supervisor.helper.retry(warn),supervisor.helper.retry_exhausted(error),supervisor.helper.recovered(info),supervisor.helper.create_slow(warn, any create Docker takes 5 s or more to answer), andsupervisor.helper.cleanup.deferred/.removed/.unconfirmed. Retry events name the step the deadline hit (create,start,read,exit, ...), so a Docker stall can be told apart from a reader that hung.FRESHELL_TEST_BROKER_DELAY_HELPER_CREATE=<prefix>:<ms>[:<count>]holds helper creates in the test Docker proxy to reproduce a stall. Harness cleanup waits for every forwarded request to finish, removes helpers Docker created late, and now also removes any Claude candidate scratch volume the run left behind (through the proxy's ownership check).docs/development/managed-runtime.mddescribes the helper lifecycle, the retry and the budgets.Tests
cargo test -p freshell-supervisor: 205 passed. The push gate'sfreshell-serverrun: 1273 passed. Clippy-D warnings, fmt and typecheck clean.Parity runs (
provider-parity-contract.test.ts, local Docker)On the final head, with the host's 1-minute load at 8–21: 4 of 4 passed (312 s, 309 s, 310 s, 649 s). A run with a 30 s stall injected into the first source-reader create of each rig also passed (464 s): both stalls were retried (
step: "create") and the reads recovered at 50 s and 33 s.Earlier runs while other agents' stress tests held the host's load at 250–490 (on 96 cores) failed 6 of 7 times. None left a helper container or volume behind, and the logs show where each one went:
After merging main (#866), two more runs failed while other work on the host wrote 6–7 GB bursts to the disk: source-reader and mountpoint creates took 65–71 s, and once the OpenCode input publisher's cleanup (not changed here) timed out. Every late helper was still removed. In the four green runs above, the disk never held more than 3 GB unwritten.
Release builds: the parity rig requires the debug session-host build for its fresh-agent fixtures (the server is already built release), so these runs use the harness's normal builds.
Not in this PR
🤖 Generated with Claude Code