Repository navigation
fix: recover agent sessions and reconnect clients after a server restart - #122
Merged
Merged
Conversation
Restarting the server for a deploy killed every running agent session for good. Provider processes are children of the server, so they die with it, but nothing re-derived their state at boot: the projection kept `status: running` and a non-null `activeTurnId`, so the thread showed "Working" forever. Recovery was lazy — it only ran when the next operation was routed to the thread — and the state was self-reinforcing: the reaper skips threads with an active turn, settle is rejected while running, and Stop respawned the agent only to silently no-op on a session with no turn. Even a clean shutdown left this behind, because the reactors are torn down before the adapters emit their exit events. A boot-time sweep now marks those sessions interrupted, clears the stuck turn, releases approval and user-input requests whose callbacks died with the process, and restarts the interrupted turn with a prompt that restates the original request and tells the agent to check for partially completed work before redoing it. The agent resumes from its persisted resume cursor, so a deploy no longer costs the user a session. Also: Stop settles the thread instead of respawning a dead session, session start is bounded so one wedged child cannot hang the shared command worker, bindings are reconciled at boot to survive SIGKILL, and shutdown stops adapters concurrently so it fits the launcher's 5s kill deadline. The sweep lives in its own file and is wired in with three lines, to keep upstream merges cheap. Auto-restart is one exported constant away from off. Model: Claude Opus 5 (1M context). Harness: Claude Code in T3 Code.
tusharbhardwaj-bk
force-pushed
the
t3code/upma
branch
from
August 19, 2026 12:25
80df0f8 to
73e1d7a
Compare
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: unavailable · PR result: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
A deploy can leave the client holding a socket that never delivers a close event — common with a proxy or tunnel in the path. The supervisor only leaves the connected phase on a close or an explicit signal and has no timer while connected, so it believed it was connected indefinitely and the only way back was the Reconnect button. A durable subscription that lost its transport made this worse: it logged, drained, and waited for a session that was never coming. The connected phase now probes an idle connection every 20 seconds, reusing the same guarded probe a foreground wake already runs, so a silently dead transport is detected in about 30 seconds and reconnects on the normal backoff ladder. A healthy connection is untouched. Subscriptions that lose their transport report the session instead of waiting on it; the report is gated on being the live session, which collapses duplicates and drops stale ones. Heartbeat failures deliberately do not skip the first backoff rung — nobody is waiting on the app, and that is what keeps a flapping server from becoming a reconnect storm. Model: Claude Opus 5 (1M context). Harness: Claude Code in T3 Code. chore(client): mark the fork's reconnect edits with T3-CUSTOM markers
tusharbhardwaj-bk
force-pushed
the
t3code/upma
branch
from
August 19, 2026 12:32
73e1d7a to
778d72a
Compare
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.
Restarting the server for a deploy broke every running agent session. Sessions sat on "Working" forever, automatic recovery never finished, and the only way out was right-clicking the session and reconnecting by hand. It turned out to be two independent faults.
Server — nothing recovered sessions at boot. Provider processes are children of the server, so they die with it, but nothing re-derived their state on startup: the projection kept
status: runningwith a non-nullactiveTurnId, so the thread rendered as working indefinitely. Recovery was lazy — it only ran when the next operation happened to route to that thread — and the stuck state defended itself: the reaper skips threads with an active turn, settle is rejected while running, and Stop respawned the agent only to no-op silently on a session that had no turn. Even a clean shutdown left this behind, because the reactors are torn down before the adapters emit their exit events, so nobody is listening.A boot-time sweep now marks those sessions interrupted, clears the stuck turn, releases approval and user-input requests whose in-process callbacks died with the server, and restarts the interrupted turn. The restart prompt restates the original request and tells the agent to check for partially completed work before redoing anything — the turn may have already edited files before it died. The agent resumes from its persisted resume cursor, so a deploy no longer costs a session.
Also fixed on the way: Stop settles a thread whose session is gone instead of respawning it, session start is bounded at 60s so one wedged child cannot hang the shared provider command worker, bindings are reconciled at boot so a SIGKILL is survivable, and shutdown stops adapters concurrently so it fits the launcher's 5s kill deadline.
Client — a half-open socket looked healthy forever. The connection supervisor only leaves the connected phase on a close event or an explicit signal, and had no timer while connected. A restart behind a proxy or tunnel can leave a socket that never delivers a close, so the client believed it was connected indefinitely — which is exactly what the manual Reconnect button was working around. Subscriptions that lost their transport made it worse by logging and waiting for a session that was never coming.
The connected phase now probes an idle connection every 20s, reusing the same guarded probe a foreground wake already runs, so a dead transport is caught in ~30s and reconnects on the normal backoff ladder. Healthy connections are untouched. Subscriptions that lose their transport report the session instead of waiting on it, gated on it still being the live session so duplicates collapse and stale reports drop. Heartbeat failures deliberately do not skip the first backoff rung — nobody is waiting on the app, and that is what stops a flapping server becoming a reconnect storm.
Upstream merge cost
Nearly all of the new logic is in one fork-owned file,
ProviderSessionRestartSweep.ts, wired intoProviderCommandReactor.start()with three lines. Nothing upstream was refactored or extracted; where the sweep needed logic that already existed indecider.tsit mirrors it locally, and a test pins the two against the real decider so the duplication cannot drift silently. The remaining edits to upstream files are single-line or few-line insertions. Touch points to re-verify on each upstream merge:ProviderCommandReactor.start()— sweep construction, capture, and parked runProviderCommandReactor.processTurnInterruptRequested— thecatchCauseProviderService— interrupt guard,allowRecovery: false, start timeout, boot reconcile,stopAllboundingCodexSessionRuntime.interruptTurn— the no-active-turn failuresupervisor.ts— heartbeat hunk inmonitorConnectedLease,notifySessionSuspectrpc/client.ts— the escalation callThese are upstream bugs rather than fork behaviour, so the intent is to offer both fixes upstream and delete the fork copies once they land.
Verification
Auto-restart is one exported constant (
AUTO_RESTART_INTERRUPTED_TURNS) away from off. Worth knowing for the first deploy: any thread currently stuck in this state will be swept and restarted on startup.Model: Claude Opus 5 (1M context). Harness: Claude Code in T3 Code.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.