fix(server): Claude session crons no longer die with the idle session#23636
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, and ProviderSessionManager.releaseIfStillIdle closes that process after the 30-minute idle timeout, because ClaudeAdapterV2's hasPendingBackgroundWork never counts pending crons. The job never runs, and CronList is empty after the next resume.
Repro on main 8d3b061121 (web dev server, Claude Sonnet 5.5): at 11:43 UTC ask Claude to CronCreate a one-shot job for 12:20 that appends date -u to a file. CronList shows 4e805017 … (one-shot) [session-only], next run 12:20. Leave the thread idle. The provider session went to stopped at 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.- While any count is above zero, the session runtime's
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. - The count is cleared only when the CLI process ends or is replaced: in
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. - No other change: per-thread Working/settled state still uses
hasPendingBackgroundWorkForThread, which is unchanged. No new setting, contract field or persisted state.
Limits that remain:
- The existing 4-hour
maxIdlePinMscap still applies, so a one-shot set more than about 4 hours ahead with no activity in between is still released. - A recurring cron that fires resets that budget, so a
/loopkeeps its CLI alive while it keeps firing. - Replacing the process (a model or permission-policy change on the next turn) still drops the crons, because they die with the CLI.
- If the turn a one-shot cron fires ends in a failed result, no
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:
| Test | Before | After |
|---|---|---|
keeps a session with a pending session cron alive until no cron remains | fails on main: a pending session cron must keep the idle session alive: expected false to be true | passes |
forgets session crons when the next turn is interrupted before Stop | fails without the clear on process exit: expected true to be false | passes |
keeps session crons pinned when the next turn fails without closing the CLI | fails if a failed result clears the count: a failed turn must not forget crons the live CLI still holds: expected false to be true | passes |
ClaudeAdapterV2.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:
Before (main 8d3b061121) | After (fix core 9fdecd3927) | |
|---|---|---|
| Cron scheduled | 11:43, one-shot for 12:20 (4e805017) | 12:30, one-shot for 13:08 (1b974688) |
| 30-minute idle check | provider session stopped at 12:13:33.956 (releaseIfStillIdle), CLI exited | orchestration-v2.driver-session.idle-release-deferred at 13:00:47, CLI still running |
| Fire time | nothing written by 12:21:47 | file written Thu Oct 22 13:08:03 UTC 2026, fired turn shown in the thread |
After: the fired turn at 1:08 PM in the scheduling thread.
Not checked:
- Desktop and mobile clients. The change is in the server's Claude adapter only, so no client code is touched.
- Other providers. Session crons are a Claude CLI feature.
- The runtime check ran on
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 past maxIdlePinMs: the cap check in ProviderSessionManager.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:
- A query replacement (model, mode or MCP change between turns) still closes the CLI and drops crons silently. Same on main:
liveProcessRunsBackgroundWorkignores crons. - The Stop hook writes the count without checking that its query still owns the thread. A late Stop from a closing CLI could re-set the count, which the cap then bounds.
- A failed wake turn, or a CronDelete in a turn that then fails, leaves a positive count until the cap.
The SDK Stop hook 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.
| Row | Before | After |
|---|---|---|
| R1 one-shot cron ~37 min out | idle check 14:11:00 released the session, cron never fired (transcript) | 14:10:58 idle-release-deferred, cron fired 14:18:01, file append 14:18:07Z (transcript) |
| R3 cron deleted before firing | released 14:11:20 (transcript) | released 14:11:21, no deferral (transcript) |
| R4 interrupt after CronCreate | released 14:14:59, no fire (transcript) | released 14:13:37, no fire (transcript) |
| R6 no cron | released 14:10:50 (transcript) | released 14:10:48 (transcript) |
Logs: server-before.log, server-after.log, idle spans before / after, session states, everything (redacted).
Drivers (sha256 list):
- start-rows.sh
5954f78be47373e24e24ceefcf4d388965c0c25835a58e022743c8eec9c09465 - r4.sh
5aefe4dfad63f261171f0780bb2e2c1f4dbe3b7985f773a94ebe0b0e628ceb7b - mcp.sh
9243a937b7fe7ab3896c948f6996faeb9e252e2c9bc54b30b0afc6d9cfa48e89 - issue-mcp-token.ts
a5949d598647783f4c969bfc310a637af7501406ba4b08a39422136e8996576a - capture.sh
5112ea9bd40f62c2eebf6f521786a2cf8ba36fbbbdfc3a8cd5ab055a19684fcf - sessions.sh
446b173e04035a42252cc1ea630cb2dae48fb611f942a29b3a9d1a08ad9ac904
Verification log
- At the head,
ClaudeAdapterV2.test.tspassed (210/210, including the 3 session-cron tests), and the servervp run typecheckfound no errors. - R1 reproduces the PR's original before/after at the head; the earlier capture was on 9fdecd3927.
- R3 only discriminates on the after side, because main always releases.
- R4 does not isolate the interrupt count-clear (49a57cd2d1). T3's interrupt ends the CLI process on both builds, so the session is released either way. Only the unit test covers the clear.
- Unverified: R2 recurring cron (not captured, and the unit fixture is
recurring: falseonly). R5 failed-turn pin (2d33979623; 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). - Each row was captured once (n=1). One accidental extra main sample, where the interrupt arrived after the turn ended, agrees with R1-before.
- Claude Opus 5.5 (lead, Review B, capture) and Claude Sonnet 5.5 (Review A, audits 1 and 2) in T3 Code via the Claude Code harness.
Checks
Added 5 commits
- fix(server): keep session crons pinned after a failed Claude turn2d33979
- fix(server): clear pending session crons when a Claude turn is interrupted49a57cd
- Merge origin/main into fix/issue-134397c9fae3
- fix(server): pending Claude session crons keep the session alive9fdecd3
- test(server): reproduce #13439, pending Claude session crons don't keep the session alivec9ca752
Approvability
Verdict: Approved at 7c9fae3
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.
| Provider | Metric | Main baseline | This PR | Impact | PR ceiling | |
|---|---|---|---|---|---|---|
| Codex | Total thread wire | 4.9 KiB | 5.0 KiB | +23 B (+0.5%) | 6.8 KiB | ✅ |
| Codex | Thread snapshot wire | 3.8 KiB | 3.8 KiB | 0 B (0.0%) | 4.9 KiB | ✅ |
| Codex | Live turn WebSocket wire | 1.2 KiB | 1.2 KiB | +23 B (+1.9%) | 2.0 KiB | ✅ |
| Codex | Live turn WebSocket decoded | 20.8 KiB | 20.9 KiB | +41 B (+0.2%) | 29.3 KiB | ✅ |
| Codex | Live turn messages | 1 | 2 | +1 (+100.0%) | 8 | ✅ |
| Claude | Total thread wire | 5.0 KiB | 5.0 KiB | −7 B (−0.1%) | 6.8 KiB | ✅ |
| Claude | Thread snapshot wire | 3.8 KiB | 3.8 KiB | 0 B (0.0%) | 4.9 KiB | ✅ |
| Claude | Live turn WebSocket wire | 1.2 KiB | 1.2 KiB | −7 B (−0.6%) | 2.0 KiB | ✅ |
| Claude | Live turn WebSocket decoded | 21.2 KiB | 21.2 KiB | 0 B (0.0%) | 29.3 KiB | ✅ |
| Claude | Live turn messages | 2 | 2 | 0 (0.0%) | 8 | ✅ |
Baseline: f054d1b · PR result: 7c9fae3 · Source CI: success
Scenario and decoded snapshot size
10 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.
- Codex decoded thread snapshot: 108.5 KiB
- Claude decoded thread snapshot: 108.8 KiB
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
⚙️ Run configuration
- Source mode: Auto
- Repository: pingdotgg/t3code
- Branch: fix/issue-13439
- File: .coderabbit.config.ts
- Commit: 7c9fae3
- Layers: Repository TS config
- Review profile: CHILL
- Plan: Team
- Run ID:
0bf727cb-0773-4ae1-b274-d18c5f8aca96
📥 Commits
Reviewing files that changed from the base of the PR and between f054d1b8018721bd997d99cbb918a7b17bf098a4 and 7c9fae306644524453aa794bb4f911c266c94840.
📒 Files selected for processing: 2
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.tsapps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.
📝 Walkthrough
The Claude adapter now records session cron counts from Stop hooks by native thread. Positive counts mark background work as pending. Process reset and query exit clear the stored count. Tests cover cron reporting, interruption, and failed turns.
Changes: Claude session cron tracking
Layer / File(s) Summary Configure hooks and record cron counts
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.tsQuery options accept and forward SDK hooks. The Stop hook stores the reported session cron count for the native thread. Clear counts and detect pending work
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts,apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.tsProcess reset and query exit clear stored counts. Positive counts mark background work as pending. Tests cover reported crons, interruption before Stop, and failed turns without CLI shutdown. Priority: ➖ Normal
Estimated code review effort: 3 (Moderate) | ~20 minutes
Change: Bug fix · Severity of issue fixed: Medium
Sequence Diagram(s)
mermaid1 sequenceDiagram 2 participant ClaudeCLI 3 participant ClaudeStopHook 4 participant ClaudeAdapter 5 participant PendingWorkCheck 6 ClaudeCLI->>ClaudeStopHook: Report session_crons 7 ClaudeStopHook->>ClaudeAdapter: Store count by native thread 8 ClaudeAdapter->>PendingWorkCheck: Check for pending background work 9 PendingWorkCheck-->>ClaudeAdapter: Return true when any count is positive Suggested reviewers:
t3dotgg
Merge Risk: ⚪ Minimal · up to 7c9fa
Pending Claude session crons keep the session alive through the idle check, subject to the existing four-hour cap. No actionable merge-blocking risk remains after normal checks.
Pre-merge checks: Passed 3 · Failed 1
Failed Failed checks: 1 warning
Check name Status Explanation Resolution Linked Issues check Warning Issue #13439requires a scheduled/scheduleorCronCreatejob to run at its scheduled time. The change satisfies the idle-release case:StopHookInput.session_cronskeeps the Claude CLI pinned wh…Add process-replacement handling that preserves or recreates the scheduled session cron so the job survives CLI replacement. Add automated coverage for replacement and subsequent scheduled execution. Passed Passed checks: 3 passed
Check name Status Explanation Title check Passed The title is concise, uses the repository's conventional format, and clearly describes the fix for Claude session crons lost during idle session release. Out of Scope Changes check Passed The reported changes are limited to ClaudeAdapterV2and its focused tests. The hook, pending-work state, cleanup rules, and idle-release behavior directly support issue#13439. The tests validate th…Description check Passed The pull request description includes all required sections: Problem, Change, Scope and approval, and Verification. It explains the reproduction, implementation, scope limits, linked issue, maintainer… Full details: Out of Scope Changes check
Explanation
The reported changes are limited to
ClaudeAdapterV2and its focused tests. The hook, pending-work state, cleanup rules, and idle-release behavior directly support issue#13439. The tests validate the changed lifecycle behavior. No unrelated product area or unrelated contract or persisted state change is reported.
Full details: Description check
Explanation
The pull request description includes all required sections: Problem, Change, Scope and approval, and Verification. It explains the reproduction, implementation, scope limits, linked issue, maintainer confirmation, focused tests, runtime evidence, unverified areas, and the agent model and harness.
Full details: Linked Issues check
Explanation
Issue
#13439requires a scheduled/scheduleorCronCreatejob to run at its scheduled time. The change satisfies the idle-release case:StopHookInput.session_cronskeeps the Claude CLI pinned while the count is positive, and focused tests cover cron retention and cleanup. The issue also identifies process replacement as a failure mode. The change explicitly retains no persisted cron state and still loses jobs when the CLI process is replaced. Therefore, the direct issue requirement is only partially implemented.Resolution
Add process-replacement handling that preserves or recreates the scheduled session cron so the job survives CLI replacement. Add automated coverage for replacement and subsequent scheduled execution.
✨ Finishing Touches
📝 Generate docstrings
- Commit to this branch
- Create a new PR
🧪 Generate unit tests (beta)
- Commit to this branch
- Create a new PR
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Comment @coderabbitai help to get the list of available commands.
Note
🤖 Claude Opus 5.5 responding on behalf of Theo
Related: #28104 merged as 4ac97be. It adds durable one-time T3 scheduled tasks that agents can use instead of a Claude CronCreate one-shot. It does not change Claude session crons, so this PR is still needed for those.