Repository navigation
fix(server): Claude session crons no longer die with the idle session - #23636
juliusmarminge wants to merge 5 commits into
Conversation
…ep the session alive An idle Claude session whose Stop hook reports a pending session cron (CronCreate, ScheduleWakeup, /loop) must report pending background work, so ProviderSessionManager does not release it before the cron fires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude Code keeps CronCreate, ScheduleWakeup and /loop jobs in the CLI process, so idle release killed the session before a job could fire. Register a no-op Stop hook that records the latest session_crons snapshot and report pending background work while it is non-empty. The existing idle pin cap still bounds how long a session is held. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…upted An interrupted or failed turn never reaches the SDK Stop hook, so the session cron count it last reported stayed set and pinned the idle session after its process (and crons) were gone. Clear the count together with the background-task roster. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A result-driven failed turn (usage limit, overload, error subtype) leaves the Claude CLI running with its session crons, and the SDK fires StopFailure, not Stop. Clearing the cron count on every non-completed finalization let the idle release drop the session and lose the job. Clear it only when the process ends or is replaced: on query exit and in the process reset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ApprovabilityVerdict: Approved at Macroscope's review found this PR approvable — This focused server fix keeps Claude CLI sessions alive when their existing session-only crons are pending, while clearing the pin when the owning process ends. It adds targeted lifecycle regression tests without changing product defaults, schemas, or static-analysis configuration. You can add or adjust custom eligibility rules. Learn more. |
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: 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. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
Merge Risk: ⚪ Minimal · up to
|
|
Note 🤖 Claude Opus 5.5 responding on behalf of Theo Related: #28104 merged as |
Problem
A Claude thread that schedules a session cron (
CronCreate,ScheduleWakeup,/loop) loses the job if it fires more than 30 minutes after the thread goes idle. The crons live only inside the Claude CLI process, andProviderSessionManager.releaseIfStillIdlecloses that process after the 30-minute idle timeout, becauseClaudeAdapterV2'shasPendingBackgroundWorknever counts pending crons. The job never runs, andCronListis empty after the next resume.Repro on main
8d3b061121(web dev server, Claude Sonnet 5.5): at 11:43 UTC ask Claude toCronCreatea one-shot job for 12:20 that appendsdate -uto a file.CronListshows4e805017 … (one-shot) [session-only], next run 12:20. Leave the thread idle. The provider session went tostoppedat 12:13:33 and the CLI exited. The file was never written. A control cron set inside the idle window (11:54) fired and rendered in its thread, so firing works whenever the process is still alive.Change
ClaudeAdapterV2registers an SDKStophook. The SDK reports a session's pending crons only throughStopHookInput.session_crons. The hook stores that snapshot's length per native thread and returns{ continue: true }, so Claude's behaviour and any userStophooks from settings are unaffected.hasPendingBackgroundWorkreturns true.ProviderSessionManagerthen defers the idle release the same way it does for background tasks, under its existingmaxIdlePinMscap. EachStopoverwrites the snapshot, so a fired one-shot or a deleted cron drops out at the nextStop.resetBackgroundTaskStateForNativeThreadProcess, and at the top offinalizeActiveTurnAfterQueryExit(interrupt, close, transport error). A result-drivenfailedturn, such as a usage limit or a 529, keeps the CLI and its crons alive, so it leaves the count alone.hasPendingBackgroundWorkForThread, which is unchanged. No new setting, contract field or persisted state.Limits that remain:
maxIdlePinMscap still applies, so a one-shot set more than about 4 hours ahead with no activity in between is still released./loopkeeps its CLI alive while it keeps firing.Stopsnapshot follows, so the spent cron keeps the session pinned until the nextStopor the 4-hour cap.Scope and approval
Fixes #13439
GitCafe: https://git.cafe/pingdotgg/t3code/issues/13439
Triaged bug via the automated issue flow. A maintainer confirmed the cause on the issue: the idle reaper, with session crons not modelled as liveness.
Verification
Focused tests in
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts, each red before its fix:keeps a session with a pending session cron alive until no cron remainsa pending session cron must keep the idle session alive: expected false to be trueforgets session crons when the next turn is interrupted before Stopexpected true to be falsekeeps session crons pinned when the next turn fails without closing the CLIa failed turn must not forget crons the live CLI still holds: expected false to be trueClaudeAdapterV2.test.ts: 210/210 passed.ProviderSessionManager.test.ts: 41/41 passed.tsc --noEmitforapps/server: clean.vp lintandvp fmt --checkon the changed files: no new findings.Runtime check in the real app. This is a server-only fix, so the evidence is the server transcript and the thread itself:
8d3b061121)9fdecd3927)4e805017)1b974688)stoppedat 12:13:33.956 (releaseIfStillIdle), CLI exitedorchestration-v2.driver-session.idle-release-deferredat 13:00:47, CLI still runningThu Oct 22 13:08:03 UTC 2026, fired turn shown in the threadAfter: the fired turn at 1:08 PM in the scheduling thread.
Not checked:
9fdecd3927. The two later commits change only when the count is cleared after an interrupt or a failed turn, which the unit tests cover.Claude Opus 5.5 and Claude Sonnet 5.5 in T3 Code via the Claude Code harness.
🤖 Generated with Claude Code
Maintainer review and independent audit (head 7c9fae3066)
Reviews: Claude Sonnet 5.5:
VERDICT: MERGE. Claude Opus 5.5:VERDICT: MERGE. Both found that a stale cron count cannot pin a session pastmaxIdlePinMs: the cap check inProviderSessionManager.ts(~1133-1167) does not depend on the adapter, and a stale count emits no provider events, so the worst case is about 30 min idle + 4 h. Low-severity follow-ups, not blockers:liveProcessRunsBackgroundWorkignores crons.The SDK
Stophook returning{ continue: true }runs alongside user and plugin Stop hooks and does not override them. Neither reviewer found a semantic conflict with https://git.cafe/pingdotgg/t3code/pulls/23561 or https://git.cafe/pingdotgg/t3code/pulls/22792. Auth, permissions, modes and MCP are not touched.Audit:
VERDICT: SUFFICIENT. Real servers, before = main 8d3b061121 (the PR's merge-base), after = 7c9fae3066. Claude Sonnet 5.5, isolated synthetic--base-dir. The idle timeout is hard-coded at 30 min, so both builds ran in parallel at the real timeout.idle-release-deferred, cron fired 14:18:01, file append14:18:07Z(transcript)Logs: server-before.log, server-after.log, idle spans before / after, session states, everything (redacted).
Drivers (sha256 list):
5954f78be47373e24e24ceefcf4d388965c0c25835a58e022743c8eec9c094655aefe4dfad63f261171f0780bb2e2c1f4dbe3b7985f773a94ebe0b0e628ceb7b9243a937b7fe7ab3896c948f6996faeb9e252e2c9bc54b30b0afc6d9cfa48e89a5949d598647783f4c969bfc310a637af7501406ba4b08a39422136e8996576a5112ea9bd40f62c2eebf6f521786a2cf8ba36fbbbdfc3a8cd5ab055a19684fcf446b173e04035a42252cc1ea630cb2dae48fb611f942a29b3a9d1a08ad9ac904Verification log
ClaudeAdapterV2.test.tspassed (210/210, including the 3 session-cron tests), and the servervp run typecheckfound no errors.recurring: falseonly). R5 failed-turn pin (2d33979; a real 429/529 can't be triggered on demand; unit test only). R7maxIdlePinMscap (4 h, hard-coded; existing code, unchanged here, no direct test).