fix(server): Claude session crons no longer die with the idle session#23636

open5
main
fix/issue-13439
opened11d agobyJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 1d ago

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

  • ClaudeAdapterV2 registers an SDK Stop hook. The SDK reports a session's pending crons only through StopHookInput.session_crons. The hook stores that snapshot's length per native thread and returns { continue: true }, so Claude's behaviour and any user Stop hooks from settings are unaffected.
  • While any count is above zero, the session runtime's hasPendingBackgroundWork returns true. ProviderSessionManager then defers the idle release the same way it does for background tasks, under its existing maxIdlePinMs cap. Each Stop overwrites the snapshot, so a fired one-shot or a deleted cron drops out at the next Stop.
  • The count is cleared only when the CLI process ends or is replaced: in resetBackgroundTaskStateForNativeThreadProcess, and at the top of finalizeActiveTurnAfterQueryExit (interrupt, close, transport error). A result-driven failed turn, 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 maxIdlePinMs cap 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 /loop keeps 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 Stop snapshot follows, so the spent cron keeps the session pinned until the next Stop or 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:

TestBeforeAfter
keeps a session with a pending session cron alive until no cron remainsfails on main: a pending session cron must keep the idle session alive: expected false to be truepasses
forgets session crons when the next turn is interrupted before Stopfails without the clear on process exit: expected true to be falsepasses
keeps session crons pinned when the next turn fails without closing the CLIfails if a failed result clears the count: a failed turn must not forget crons the live CLI still holds: expected false to be truepasses
  • ClaudeAdapterV2.test.ts: 210/210 passed.
  • ProviderSessionManager.test.ts: 41/41 passed.
  • tsc --noEmit for apps/server: clean.
  • vp lint and vp fmt --check on 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 scheduled11:43, one-shot for 12:20 (4e805017)12:30, one-shot for 13:08 (1b974688)
30-minute idle checkprovider session stopped at 12:13:33.956 (releaseIfStillIdle), CLI exitedorchestration-v2.driver-session.idle-release-deferred at 13:00:47, CLI still running
Fire timenothing written by 12:21:47file 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.

after-thread.png

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: liveProcessRunsBackgroundWork ignores 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.

RowBeforeAfter
R1 one-shot cron ~37 min outidle 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 firingreleased 14:11:20 (transcript)released 14:11:21, no deferral (transcript)
R4 interrupt after CronCreatereleased 14:14:59, no fire (transcript)released 14:13:37, no fire (transcript)
R6 no cronreleased 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.ts passed (210/210, including the 3 session-cron tests), and the server vp run typecheck found 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: false only). R5 failed-turn pin (2d33979623; a real 429/529 can't be triggered on demand; unit test only). R7 maxIdlePinMs cap (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

21 successful · 13 skipped
All checks passed
7c9fae3

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
Synced from GitHub

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.

Synced from GitHub

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire4.9 KiB5.0 KiB+23 B (+0.5%)6.8 KiB✅
CodexThread snapshot wire3.8 KiB3.8 KiB0 B (0.0%)4.9 KiB✅
CodexLive turn WebSocket wire1.2 KiB1.2 KiB+23 B (+1.9%)2.0 KiB✅
CodexLive turn WebSocket decoded20.8 KiB20.9 KiB+41 B (+0.2%)29.3 KiB✅
CodexLive turn messages12+1 (+100.0%)8✅
ClaudeTotal thread wire5.0 KiB5.0 KiB−7 B (−0.1%)6.8 KiB✅
ClaudeThread snapshot wire3.8 KiB3.8 KiB0 B (0.0%)4.9 KiB✅
ClaudeLive turn WebSocket wire1.2 KiB1.2 KiB−7 B (−0.6%)2.0 KiB✅
ClaudeLive turn WebSocket decoded21.2 KiB21.2 KiB0 B (0.0%)29.3 KiB✅
ClaudeLive turn messages220 (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.

Synced from GitHub

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
📥 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.ts
  • apps/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.ts
Query 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.ts
Process 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)

mermaid
1sequenceDiagram
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 nameStatusExplanationResolution
Linked Issues checkWarningIssue #13439 requires a scheduled /schedule or CronCreate job to run at its scheduled time. The change satisfies the idle-release case: StopHookInput.session_crons keeps 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 nameStatusExplanation
Title checkPassedThe 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 checkPassedThe reported changes are limited to ClaudeAdapterV2 and its focused tests. The hook, pending-work state, cleanup rules, and idle-release behavior directly support issue #13439. The tests validate th…
Description checkPassedThe 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 ClaudeAdapterV2 and 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 #13439 requires a scheduled /schedule or CronCreate job to run at its scheduled time. The change satisfies the idle-release case: StopHookInput.session_crons keeps 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.