Skip to content

fix(supervisor): retry stalled Claude source reads and remove helpers Docker creates late - #867

Merged
danshapiro merged 21 commits into
mainfrom
fix/managed-helper-create-retry
Oct 23, 2026
Merged

danshapiro merged 21 commits into
mainfrom
fix/managed-helper-create-retry

Conversation

@danshapiro

@danshapiro danshapiro commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

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)

  • No more leaks. Each helper create runs as its own task, so it keeps going when the caller stops waiting. The supervisor owns a helper until Docker confirms it is gone: cleanup waits for Docker's answer, then removes by exact name. Anything still unanswered, or whose removal ran out of time or failed, goes to a background cleanup task that keeps removing it (ownership is re-checked every try) for up to 120 s. This covers the source reader, the candidate reader and its scratch volume, and the mountpoint helper on both the candidate and launch paths.
  • Bounded retry of the reader. A reader attempt that timed out, or whose create or start Docker refused with a server error or never answered, is retried. Client errors, ownership or topology changes, a reader that exits with an error, and unconfirmed cleanups stay final. Each attempt keeps the 25 s limit; the whole read gets up to 90 s (enough to wait out a 48 s stall and then do one full retry). A retry starts only after the previous attempt's helpers are gone, so retries never pile creates onto a stalled Docker. Waits between attempts are 1 s and 2 s.
  • Recovery limits unchanged from Managed sessions: fix slow session-host startup and mismatched time limits #861. A Claude recovery's callback still waits 105 s. A read inside a recovery now ends, cleanup included, 5 s before the recovery's own time is up, so a retry only uses what is left of the callback and never outlives it.
  • Logs (structured JSON lines in the supervisor lifecycle log): 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), and supervisor.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.
  • Test harness. 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.md describes the helper lifecycle, the retry and the budgets.

Tests

  • Unit tests (each seen failing first): refused create retried; late create removed; late scratch-volume create removed; a removal that runs past its window is finished in the background (candidate scratch volume and launch mountpoint helper); a recovery read ends inside its recovery scope; a read ends by its caller's deadline; retry events name the step; slow creates logged; proxy hold and wait-for-answers; harness removes leftover candidate volumes and refuses a foreign one.
  • Regression guards, checked by breaking the code: client errors not retried, exhaustion after three attempts, the launch hand-off, the scratch-volume wait.
  • The retry tests use second-long windows; four parallel copies pass at a load of about 440 on 96 cores.
  • cargo test -p freshell-supervisor: 205 passed. The push gate's freshell-server run: 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:

  • Twice the reader's retry recovered, but the whole freshclaude create then ran past the test's own 90 s wait.
  • Twice a removal took longer than the flat 5 s cleanup window and made the read fail although time remained. The last fix commit lets a removal use the time the retry already reserved.
  • Once the launch-path mountpoint helper (which has no retry) took 29 s to create.
  • Once 6.6 GB of unwritten data appeared on the disk within 12 s and took about 70 s to drain, longer than the stall the 90 s budget is sized for.

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

  • The launch-path mountpoint helper (30 s, no retry) has the same stall exposure; it can reuse this retry with its own policy.
  • The history reader, native-auth reader and input publisher have the same create-then-give-up leak; they can reuse the same create/cleanup pieces.
  • A supervisor that crashes mid-read cannot run its own background cleanup (the harness now removes what a test run leaves).
  • First-creation (not recovery) Claude preparation has no overall limit at the web server; each read is bounded at 95 s.
  • The input publisher's cleanup has the same stall exposure (seen once in a run after the merge).
  • The parity test waits 90 s for a freshclaude create, which is shorter than one recovered stall plus a normal create on a loaded host.

🤖 Generated with Claude Code

danshapiro and others added 21 commits October 22, 2026 18:07
… 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
danshapiro merged commit 2f74daf into main Oct 23, 2026
8 of 10 checks passed
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>
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.

1 participant