Repository navigation
Description
Before submitting
- I searched open and closed issues for
status:compacting,Session stopped.,
compaction interruption and session eviction. The closest match is [Bug]: Claude session replacement suppresses exit and silently stops live subagent tasks #12694
(same-thread session replacement with a suppressed exit); this report is a
different shape — the surviving session belongs to a different thread, and
session.exitedis emitted here. - This report includes the full observed event chain plus a clean control run
on the same build minutes later.
Area
apps/server
Version
t3 desktop 0.0.42, provider claudeAgent.
Steps to reproduce
- Open thread A with a Claude provider session and a large context.
- Submit a
/compactturn to thread A (in my case an external MCP client submits
it once the session goes idle; a manual/compactshould be equivalent). - Wait for
session.state.changed { state: "waiting", reason: "status:compacting" }
on A. While A is compacting it emits nothing but astatus:compactingheartbeat
every 30 s. - While A is still compacting, cause a new session for a different thread B to
start (in my case a newly created thread activated and started its session). - Watch
~/.t3/userdata/logs/provider/events.<threadId>.logfor thread A.
Expected behavior
A session sitting in status:compacting is doing work, not idling. Starting a
session for an unrelated thread should not terminate it. If a stop is genuinely
required, the emitted reason should name the actual trigger rather than a bare
"Session stopped.", so the interrupted compaction is attributable.
Actual behavior
Thread A's compaction is interrupted ~38 s after thread B's session starts, and the
context is not compacted (token usage before the stop 109,214, after the automatic
resume 111,904; no summary entry is written).
Event chain on thread A, times UTC:
20:00:27.xxx session.state.changed { state: "waiting", reason: "status:compacting" }
20:00:57 status:compacting heartbeat
20:01:27 status:compacting heartbeat
── 20:01:05.923 session.started on thread B (different thread) ──
20:01:43.140 turn.completed { state: "interrupted", errorMessage: "Session stopped." }
20:01:45.141 session.exited { reason: "Session stopped", exitKind: "graceful" }
20:01:45.170 session.started (resume of thread A)
Control run — same build, ~5 minutes later, succeeded
Identical request on the same thread, with no session start anywhere in the
window. Two other provider sessions were already running throughout, so the machine
was not idle:
20:06:35.863 session.state.changed { state: "waiting", reason: "status:compacting" }
20:07:05 / 20:07:35 / 20:08:05 status:compacting heartbeats
20:08:31.601 session.state.changed { state: "running", reason: "status:active",
detail.compact_result: "success" }
20:08:31.617 turn.completed { state: "completed", stopReason: null }
Why I think it is session admission rather than load
Across the whole window 19:54–20:10 the provider canon logs contain exactly five
session.started events. Exactly one of them falls inside a compaction window — the
one 38 s before the kill. The successful control ran with two other sessions already
live but no new session starting. So concurrent load does not appear to interrupt a
compaction; admitting a new session appears to.
I cannot point at the predicate that chose to stop this particular session, so I am
not claiming one. A plausible shape: a session in status:compacting emits only
heartbeats and no items, so any "evict the least active session" policy would select
exactly the session that is busiest.
n = 1 in each direction, so treat the correlation as strong but not proven. The
deliberate test would be to start a new thread's session while a compaction is in
flight and see whether the compacting session dies again.
Activity
juliusmarminge commentedon Sep 21, 2026
Triage
Investigated against current
apps/serversource. This is a real user-facing failure (compaction aborted, context not reduced) with a strong but unproven correlation. It is not a duplicate of #12694.What the trace shows
The pair
turn.completed { state: "interrupted", errorMessage: "Session stopped." }session.exited { reason: "Session stopped", exitKind: "graceful" }is authored only by
ClaudeAdapter.stopSessionInternalwhen a turn is still open andemitExitEventis left on (ClaudeAdapter.ts). Callers of that path:providerService.stopSession(session-stop command, settlement cleanup, interrupt-recovery, reaper, deletion)interruptTurn(Claude treats interrupt as a hard session close)It is not:
"Claude runtime stream ended."/"Claude runtime stream failed."/"Claude runtime interrupted."before teardownstartSessionon the same thread callsstopSessionInternal(..., { emitExitEvent: false })and suppressessession.exitedA’s
session.started29ms after the exit is a laterstartSessionwith a resume cursor (recovery / a follow-up/compact), not the kill.Session admission: the hypothesized policy is not in tree
There is no cross-thread “evict the least-active session when a new session is admitted” policy.
ClaudeAdapter.startSessiononly stops an existing context for thatthreadIdProviderService.stopStaleSessionsForThreadonly stops other instances of the same threadProviderSessionReaperis 30-minute inactivity, skipsactiveTurnIdand background liveness, and is not tied tosession.startedon another threadsession.status === "starting" | "running";status:compactingis mapped to runtimewaiting, which ingestion stores asrunning, so a compacting turn should not auto-settlestatus:compactingheartbeats also do not bumpProviderSessionDirectory.lastSeenAt(only binding upserts do). That matches the “looks idle” smell, but the reaper still should spare a compacting slash-command turn becauseactiveTurnIdstays set (waitingis allowed to keep an active turn).The 38s gap after B’s
session.startedalso argues against a synchronous evict inside B’s start. If T3 stopped A because B started, it did so through some later command/reactor, not insidestartSession.Compaction shape
Claude compaction is a slash-command turn (
compaction: { type: "slash-command", command: "/compact" }), not nativecompactThread. While it runs, Claude emitssystem/statuswithstatus: "compacting"every ~30s; the adapter forwards that assession.state.changed { state: "waiting", reason: "status:compacting" }. Any explicit session stop mid-wait completes the compact as interrupted and leaves the context uncompacted — which matches the token counts (109,214 → 111,904, no summary).The second request is already true in source: every graceful stop uses the same
"Session stopped."string. Nothing in the event names the trigger (session-stop vs interrupt vs reaper vs archive).Related (not duplicates)
Suggested next step
Reproduce on current main with two logs for thread A in the same window:
~/.t3/userdata/logs/provider/events.<threadA>.log(already captured)thread.session-stop-requested,thread.turn-interrupt-requested, andprovider.session.reapedProcedure:
/compacton a large Claude thread untilstatus:compacting, then start a session on a different thread (new thread activate is enough). Repeat once.Do not flip
emitExitEventon same-thread replace; that is #12694 / #5891 and a different race.Severity: major if the cross-thread correlation reproduces; otherwise a real observability hole on an expensive Claude maintenance turn.
Leaving this open as
bug.