Skip to content

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

Open
juliusmarminge wants to merge 5 commits into
mainfrom
fix/issue-13439
Open

juliusmarminge wants to merge 5 commits into
mainfrom
fix/issue-13439

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

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:

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 --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 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.

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.

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.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 9fdecd3.
  • R3 only discriminates on the after side, because main always releases.
  • R4 does not isolate the interrupt count-clear (49a57cd). 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 (2d33979; 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.

juliusmarminge and others added 5 commits October 22, 2026 12:00
…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>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Oct 22, 2026
@macroscopeapp

macroscopeapp Bot commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

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.

@github-actions

Copy link
Copy Markdown
Contributor

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.

@juliusmarminge juliusmarminge added the bugfix Fix PR opened by an issue lead; enters the PR loop label Oct 22, 2026 — with GitCafe Production
@juliusmarminge juliusmarminge added the pr:gate-failed A land gate failed label Oct 22, 2026
@coderabbitai

coderabbitai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

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 f054d1b and 7c9fae3.

📒 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)

sequenceDiagram
  participant ClaudeCLI
  participant ClaudeStopHook
  participant ClaudeAdapter
  participant PendingWorkCheck
  ClaudeCLI->>ClaudeStopHook: Report session_crons
  ClaudeStopHook->>ClaudeAdapter: Store count by native thread
  ClaudeAdapter->>PendingWorkCheck: Check for pending background work
  PendingWorkCheck-->>ClaudeAdapter: Return true when any count is positive
Loading

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 #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 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 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 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 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.

@juliusmarminge juliusmarminge added pr:in-progress A lead is working on this PR pr:needs-julius Needs Julius: route gate, unverifiable surface, or blocked and removed pr:gate-failed A land gate failed pr:in-progress A lead is working on this PR labels Oct 22, 2026
@t3dotgg

t3dotgg commented Nov 1, 2026

Copy link
Copy Markdown
Member

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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix Fix PR opened by an issue lead; enters the PR loop pr:needs-julius Needs Julius: route gate, unverifiable surface, or blocked size:M 30-99 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: /schedule or CronCreate jobs only live inside the Claude process not in a T3code thread

2 participants