Skip to content

Managed sessions: fix slow session-host startup and mismatched time limits - #861

Merged
danshapiro merged 14 commits into
mainfrom
fix/managed-session-host-startup
Oct 23, 2026
Merged

danshapiro merged 14 commits into
mainfrom
fix/managed-session-host-startup

Conversation

@danshapiro

Copy link
Copy Markdown
Owner

Problem

The provider parity suite (test/integration/server/provider-parity-contract.test.ts) was failing on main. Managed terminal sessions timed out while starting ("timed out waiting for claude/amplifier replacement incarnation"). The supervisor's deliver_execution_grant gave up after 10 seconds, and Claude recovery got stuck as CAPABILITY_PENDING.

Root causes (with evidence)

  1. The session host really was slow to start a provider. New per-stage timing (host.grant_timing) showed that nearly all of a 10.5–12.9 s grant was spent in prepare_provider_state. That stage flushed each of the selected plugin's roughly 4,200 files to disk one at a time, at about 2 ms per file. Run one at a time, the flushes took 13.4 s; run 16 at a time, they took 1.4 s.
  2. Terminal grants had the wrong time limit. A terminal grant got the 10 s limit meant for status and stop commands, even though the host runs steps with longer limits of their own while it handles a grant. In the failing runs the host finished at 10.6–12.9 s, but the supervisor had already torn the container down.
  3. The fixed 60 s grant limit still could not cover the host's worst case. A terminal Codex start retries its 45 s app-server start up to five times (about 251 s). An exact resume first runs a 25 s provider-owned config refresh, which can add up to 32 s of recovery. So the supervisor could still give up on a dispatch the host was still working on.
  4. The Claude recovery callback had time limits that did not fit together. Its callback to the web server waited 35 s, and the scope it hands out expired after 30 s. But that callback runs two Claude source checks back to back, and each is allowed up to 35 s on its own. In an earlier failing run, the second check failed 32.3 s after the recovery was scheduled, inside a callback that was allowed only 35 s.
  5. The test Docker proxy blocked Claude recovery (a test bug). It refused the supervisor's check on the recovery scratch volume, so Claude recovery was blocked as CAPABILITY_PENDING and nothing logged why.

Fix

  • Provider file flushes now run in parallel (PROVIDER_PROJECTION_SYNC_WORKERS), and every grant records how long each stage took.
  • Terminal grants have their own 60 s limit, FRESHELL_RUNTIME_TERMINAL_DISPATCH_TIMEOUT_MS.
  • Grants now carry their own time budget (dispatch_budget_ms). The budget is the supervisor's wait minus 5 s, and never less than half the wait (55 s by default). The host runs each awaited step inside that budget and stops it at the deadline. Once the budget is spent, it does not start the provider, so the grant can still be retried. It answers with a typed HostUnreachable error that names the step it was on. host.grant_timing now also records budgetMs and budgetExhaustedBefore. As a result, the supervisor never gives up on a dispatch the host is still working on.
  • A Claude recovery callback now waits 105 s (35 s for the callback itself plus two 35 s source checks). Other callbacks keep 35 s. The recovery scope now lasts exactly as long as the callback wait it belongs to.
  • The supervisor now logs why a Claude source check was refused (supervisor.claude_source_observation.failed), and the test Docker proxy now allows recovery scratch-volume checks under the same strict rules as the launch fixture.
  • The budget tests now give each command type a different limit, so mixing up two limits makes a test fail.
  • One session-host test failed the pre-push gate with "Text file busy". Another test thread forked while a just-written fixture was still open for writing, so the fixture could not be run yet. Tests that run freshly written fixtures now retry only that error, for up to 500 ms, the same way the platform crate's fixtures already do. A new test holds the file open for writing to prove the retry works.
  • docs/development/managed-runtime.md documents each limit and where it comes from. It also notes that a stop issued while a session is starting waits until the start finishes or fails, up to the grant limit plus the callback limit during a recovery.

Tests run

  • cargo test -p freshell-runtime-protocol -p freshell-session-host -p freshell-supervisor: all passed (protocol 66; session host 164 + 4 + 29 + 5; supervisor 195).
  • Pre-push gate on the pushed head 6af75b199 (cargo test --workspace --exclude freshell-tauri --locked, clippy, fmt, typecheck): passed.
  • New tests, each first seen failing for the right reason:
    • Host: a grant whose budget is already spent is refused before the provider starts, and is not consumed. A slow awaited step is stopped at the deadline. An exact-resume grant with a budget records budgetMs.
    • Supervisor: a grant tells the host its budget (wait minus allowance). Each command type is held to its own limit; swapping the terminal and fresh-agent limits fails two tests. A Claude recovery callback can outlast an ordinary one and its scope is still valid; other callbacks keep the 35 s limit.
  • cargo clippy --tests -D warnings and cargo fmt --check on the changed crates: clean. cargo check -p freshell-server --tests: clean.
  • pnpm run test:vitest run test/unit/tooling/testing/runtime-test-broker.test.ts --config config/vitest/vitest.config.ts: 61/61 passed.
  • pnpm run test:vitest run test/integration/server/provider-parity-contract.test.ts --config config/vitest/vitest.config.ts, run 6 times locally:
Run Head Result Time
1 dd309cb40 7/7 passed 726 s
2 dd309cb40 7/7 passed 337 s
3 dd309cb40 failed (freshclaude create; see below) 98 s
4 4d6cd8f8f (after merging main) failed (freshclaude create; see below) 469 s
5 4d6cd8f8f 7/7 passed 501 s
6 4d6cd8f8f 7/7 passed 304 s

No Playwright spec was changed; the browser specs that use the test Docker proxy are local-only.

Remaining problem (not fixed here)

Runs 3 and 4 failed for a different reason from the ones above: creating a new freshclaude session failed with FRESH_AGENT_CREATE_FAILED (marked retryable). The supervisor logged Claude candidate reader timed out in one run and Claude candidate scratch cleanup failed in the other.

Before that failure, a single Docker POST /containers/create for a short-lived helper container (freshell-mountpoints-* or freshell-claude-source-*) took 18–31 s. That used up the Claude source reader's own 25 s limit. In a reproduction with timing added to the proxy, one create took 20,010 ms while other Docker calls kept answering in milliseconds and the test process was not stalled. A Docker stack dump taken during a slow create showed dockerd inside createContainerVolumesOS, mounting and unmounting the new container's root filesystem, while containerd was idle. On this shared host, the median time for these helper creates ranged from 52 ms to 501 ms depending on the run, with occasional spikes over 5 s. Session-host container creates never went above 0.4 s.

This is not caused by the grant or callback limits fixed here, and the exact cause inside Docker is still unknown. Fixing it needs a decision: either give the Claude source reader a budget that can absorb slow Docker creates, or retry the retryable create and recovery failures.

🤖 Generated with Claude Code

danshapiro and others added 14 commits October 22, 2026 11:53
A managed terminal or fresh-agent dispatch that outran the supervisor's
reply bound left no trace of where the time went once its container was
gone. Every grant now records a host.grant_timing event (severity, grant
id, outcome, refusal code, total and per-stage milliseconds) in the
incarnation's host-lifecycle.jsonl and on the host's stderr, for accepted
and refused grants alike.

This is what showed that a 4,200-file selected provider environment spent
10.5-12.8 s of a grant inside prepare_provider_state while every other
stage took milliseconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…onments dispatch

The provider projection makes every staged node durable before it is
published, and it flushed them one at a time inside the execution grant.
Each flush is its own journal commit, so a selected plugin with a
4,200-file node_modules spent 8-13 s of the grant flushing on the
qualification host (about 2 ms per file, far more under I/O pressure),
past the supervisor's grant reply bound. The host then consumed the grant
after the supervisor had already declared it unreachable.

Flushes of independent nodes now overlap on a bounded worker set so the
filesystem journal commits them together (4,200 files: 13.4 s serial,
1.4 s at 16 workers). Every node is still flushed exactly once with a full
fsync, symlinks are still not followed, the barrier still completes before
the transaction advances, and the calling thread always takes part so a
refused thread spawn cannot stall it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dget

The supervisor bounded a terminal GrantExecution reply by the 10 s
control round-trip budget, the same bound as hello, status and stop. A
grant is not a control round trip: the host answers only after it has
prepared the selected provider inputs and started the provider, and those
steps carry their own inner bounds inside the host (Codex app-server start
45 s, Claude final source reader 20 + 5 s, OpenCode native-auth reader
20 + 5 s). An outer bound shorter than the host's own inner bounds
abandoned healthy dispatches half-way: the host consumed the grant and
started the provider at 10.6-12.9 s while the supervisor had already
recorded deliver_execution_grant HOST_UNREACHABLE, torn the incarnation
down and blocked recovery as PROVIDER_UNAVAILABLE. Fresh-agent grants
already used a 60 s dispatch bound for the same reason.

Reply budgets are now resolved once into HostCommandBudgets and chosen by
what the command makes the host do: control round trips keep
FRESHELL_RUNTIME_HOST_COMMAND_TIMEOUT_MS (10 s), terminal grants use
FRESHELL_RUNTIME_TERMINAL_DISPATCH_TIMEOUT_MS (60 s, documented against
the inner bounds above) and fresh-agent grants and turn operations keep
FRESHELL_RUNTIME_FRESH_AGENT_COMMAND_TIMEOUT_MS. A silent host is still
released at the dispatch bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Claude creation or recovery whose native source observation failed
surfaced only the fixed sanitized refusal ("evidence unavailable") to the
web server, and the supervisor has no tracing subscriber, so the exact
check that stopped it was lost. A blocked Claude recovery showed nothing
but CAPABILITY_PENDING.

The observation now records supervisor.claude_source_observation.failed
in the supervisor lifecycle log (severity, soul, provider, authority kind,
candidate flag, error code and the specific refused check or reader
failure). The web caller still receives only the fixed refusal.

The candidate scratch absence probe also stops calling every non-404 a
name that is "already present": only an answered lookup proves presence,
and any other refusal is reported as absence that could not be confirmed.
Nothing is created in either case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in browser fixtures

The restricted Docker broker that fronts browser runtime fixtures could
qualify a controller-published Claude candidate only for initial
creation. A recovery candidate (registry attempt, prior generation, no
initial reservation) was refused at owner_capability, so its scratch
absence probe got a 403 and the supervisor blocked every managed Claude
terminal recovery as CAPABILITY_PENDING. The provider parity suite timed
out waiting for the Claude replacement incarnation.

Initial and recovery publications now share one qualifier for both the
absence probe and the later volume registration. A recovery candidate must
name a well-formed registry attempt and a different prior generation, its
capability must be the pending replacement of exactly that generation,
and its retained profile (reservation, binds and resource units) comes
from the frozen prior stage, the same profile the closed launch fixture
hands off explicitly. The registration also now requires the exact
candidate labels and, for a creation candidate, its reservation authority
digest, which the absence probe already required.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve send_authenticated_host_command: keep the resolved
HostCommandBudgets selection and route its fresh-agent class through
main's uses_fresh_agent_command_budget, so FreshAgentConfigure keeps the
fresh-agent budget and terminal grants keep the dispatch budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The budget tests gave terminal grants and fresh-agent operations the same
configured value, so routing a terminal grant to the fresh-agent budget (or
reading the wrong variable for it) still passed. Give control, terminal
dispatch, and fresh-agent budgets distinct values and assert that each
command class times out at its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…upervisor's wait

The 60s terminal grant wait was documented as longer than the host's inner
limits, but it is not: a terminal Codex start retries its 45s app-server
start up to five times (about 251s), and an exact resume runs the 25s
provider-owned refresh (plus up to 32s of recovery) first. A slow but
healthy dispatch could still be abandoned by the supervisor while the host
kept working on it.

No fixed wait covers every such sum, so the grant now carries its own
dispatch budget (`dispatch_budget_ms`): the supervisor's wait less a 5s
reply allowance, never less than half the wait. The host runs every
awaited dispatch stage inside it and cuts the stage at the deadline, never
crosses the native dispatch boundary once it is spent (the grant stays
unconsumed), and refuses with a typed HostUnreachable naming the stage the
budget ran out before. `host.grant_timing` records `budgetMs` and
`budgetExhaustedBefore`. The wait and the budget now have accurate docs,
including that a stop issued during activation waits for it to settle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…observations

Recovery's provider-preparation callback to the web server waited 35s and
its recovery scope expired after 30s, yet a Claude recovery's callback makes
two source observations back to back through the supervisor before it
answers, each bounded at 35s on its own. An observation that finished inside
its own bound could still be cut off by the callback around it, or find its
scope expired, and the recovery blocked as CAPABILITY_PENDING. In the
failing parity run on the previous head the candidate reader hit its own
25s limit 32.3s after the recovery was scheduled, inside a callback
allowed 35s, under a load average near 600.

A Claude recovery callback now waits 105s (the 35s callback wait plus two
35s observation bounds); every other callback keeps 35s. The recovery scope
lives exactly as long as the callback wait it serves. The timeout message
names the wait it hit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ETXTBSY

The pre-push workspace run failed provider_config_two_stage_script_is_runnable
with "Text file busy": another test thread forked while the fixture was
still open for writing, its child held a copy of that write descriptor
until it exec'd, and exec of the fixture was refused. Run freshly written
fixtures through a helper that retries only that transient refusal for up
to 500ms (the platform crate's fixtures already do), with a test that
holds a write descriptor open to prove it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Picks up #857-#860 so rust-gate runs against the current main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@danshapiro
danshapiro merged commit fc1024c into main Oct 23, 2026
7 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.

1 participant