Repository navigation
Description
What happened
"Before the cache expires (beta)" is turned on, but a Claude thread with a 316K-token context never compacted during its idle hour. The user sent their next message 35 minutes after the prompt cache had expired. The composer's compact-before-send fallback then ran /compact first (marked idleCompaction: "onReturn"), so the compaction re-read the whole context uncached.
Diagnosis
IdleCompactionService only sets a compaction timer when it sees a run.updated event with status: "completed" while the server is running. The timer lives in a FiberMap in memory. If the server restarts at any point between a run finishing and its compaction window, the timer is lost. On startup the service doesn't scan idle threads whose cache is still warm, so it never sets the timer again. The code already says so (apps/server/src/orchestration-v2/IdleCompactionService.ts):
Timers live in memory, so a server restart skips compactions that were still waiting.
That comment and the timer logic are the same on main, which also has #23929's cheaper eligibility check.
Installing a desktop update is a common trigger. The update installs while threads sit idle, which is exactly when their compactions are waiting. Here the user clicked Install 22 minutes into a 58-minute wait, and the embedded server stopped right away.
Every condition in idleCompactionWindow() passes in the current projections:
- The global setting is
idleCompaction: beforeCacheExpires, with no per-instance override. - The thread is not archived, settled, snoozed, or a subagent.
- Runtime requests are all resolved and context transfers are all consumed.
- The latest run (run 32) completed and was not a
/compact. - The active provider thread is run 32's, on the
claudeAgentdriver and instance. - Run 32 reported
usedTokens= 316,180 (above the 200K minimum) and a 1-hourpromptCacheTtlMs, with the cache expiring at 23:51:32Z.
The thread's event log has been compacted, so its state at 22:51:32Z can't be replayed directly. Pending background work is still ruled out, for two reasons:
- Run 32 was itself the notification that the thread's last background command had finished.
- No turn item of the thread was updated between 22:51:32Z and 00:26:17Z.
Suggested fix: when the service starts, find idle threads that still have a warm cache and call compactWhenDue for each one's latest completed run. idleCompactionWindow() already returns null once the cache has expired. Points to watch:
- Activation: run the scan under
forkParked, like the existing event stream. Trial and standby servers in the self-update path never resolveServerActivation, so a scan outside it could dispatch/compactagainst the live store. The deterministiccommandIdonly dedupes within one orchestrator. - Cost: pre-filter in SQL, because fix(server): skip history reads for ineligible idle compaction #23929 exists to avoid history reads for ineligible threads and [Bug]: Thread-shell and recovery queries take 2–22 s on a 26.5 GB V2 database; six proposed indexes (follow-up to #22659) #23000 reports slow thread queries on large databases. Keep only threads on Claude instances in
beforeCacheExpiresmode whose latest run iscompleted, withcompleted_atwithin the maximum cache TTL. - Overlap:
FiberMap.run, keyed by thread, already lets a laterrun.updatedreplace a timer the scan set. Runs that were active at restart become interrupted or failed, soidleCompactionWindow()returns null for them.
Steps to reproduce
- Set idle compaction to "Before the cache expires (beta)" for a Claude instance (subscription auth, so the cache TTL is 1 hour).
- In a thread, finish a turn with more than 200K tokens of context.
- Before the compaction window opens (~58 minutes after the turn), restart the server. Installing a desktop update through the in-app updater, or quitting and reopening the desktop app, both do it.
- Leave the thread idle past the cache expiry, then send a message. No server
/compactturn appears during the idle hour. Instead, the composer's compact-before-send runs after the cache is already cold.
Version
The thread finished its last turn on the desktop build installed before 0.1.5 (exact version unknown, because those traces have rotated out). The update to 0.1.5 caused the restart. The timer logic is unchanged on main.
Environment
T3 Code Desktop (Beta) 0.1.5 on macOS (darwin arm64, Darwin 27.0.0), Node v24.22.1 (Electron-bundled), Claude Code 2.1.314, model claude-opus-5-5
Evidence
# Times are UTC. Run ordinals throughout.
# orchestration_v2_projection_provider_turns, run 32's turn
run 32 completed 22:51:32Z usedTokens 316180 promptCacheTtlMs 3600000 promptCacheExpiresAt 23:51:32Z
# -> compaction window would start 23:49:32Z (LEAD_MS = 2 min)
# desktop.trace.ndjson
22:41:18 desktop.updates.applyUpdateAvailable version 0.1.5
23:13:40.611 desktop.updates.install # user clicked Install
23:13:40.617 desktop.backendInstance.stop # embedded server stopped (3.6 s)
23:13:44.214 ElectronUpdater.quitAndInstall
23:13:50.778 desktop.backendInstance.start # new process
23:13:57.108 desktop.backendInstance.onReady # server-runtime.json: serverVersion 0.1.5
# DB: no server-created compaction message (idle-compact:… / idleCompaction "beforeCacheExpires") exists for the thread
# Thread messages
run 33 00:26:17.755Z "/compact" createdBy user, idleCompaction "onReturn" # composer fallback, cache already cold
run 34 00:26:17.776Z the user's actual message, queued behind it
Related issues
- [Bug]: Idle threads compact on the first message after the cache is cold; add an off switch or a timed idle compaction #19182 (closed as a duplicate of [Bug]: Enter on a stale Claude thread runs /compact first by default (regression of #15769) #17467) asked for timed idle compaction. feat(server): compact idle Claude threads before the prompt cache expires (beta) #21682, feat(claude): idle compaction has a global default and per-instance overrides #23037 and fix(server): skip history reads for ineligible idle compaction #23929 built and refined it. None of them handles a restart in the middle of the wait.
- feat(server): Claude threads can compact before a usage limit outlasts the prompt cache #27742 (open PR) rewrites this service and keeps the in-memory timers, so a fix should expect to rebase on it.
- [Bug]: Mobile sends skip "When you return" idle compaction #27911 (mobile sends skip the "When you return" fallback) is related but a different gap.
- [Bug]: Event compaction restarts from the newest event after every restart and never reaches old imported v1 events #23659 (event compaction resetting after restart) is a different subsystem.
Fix applied or workaround
Nothing was changed on the machine. Workaround: after a restart or update, send /compact to any thread that went idle within the last hour and has a large context.
Filed by
Claude Code (claude-opus-5-5) via T3 Code triage, reviewed by Claude Fable 5.1
Activity