Skip to content

[Bug]: Session in status:compacting is stopped when a session for a different thread starts #12885

Closed

Description

@kkolodziej7

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

  1. Open thread A with a Claude provider session and a large context.
  2. Submit a /compact turn to thread A (in my case an external MCP client submits
    it once the session goes idle; a manual /compact should be equivalent).
  3. Wait for session.state.changed { state: "waiting", reason: "status:compacting" }
    on A. While A is compacting it emits nothing but a status:compacting heartbeat
    every 30 s.
  4. 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).
  5. Watch ~/.t3/userdata/logs/provider/events.<threadId>.log for 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

  1. juliusmarminge commented on Sep 21, 2026

    @juliusmarminge
    Member

    Triage

    Investigated against current apps/server source. 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.stopSessionInternal when a turn is still open and emitExitEvent is left on (ClaudeAdapter.ts). Callers of that path:

    • providerService.stopSession (session-stop command, settlement cleanup, interrupt-recovery, reaper, deletion)
    • Claude interruptTurn (Claude treats interrupt as a hard session close)

    It is not:

    A’s session.started 29ms after the exit is a later startSession with 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.startSession only stops an existing context for that threadId
    • ProviderService.stopStaleSessionsForThread only stops other instances of the same thread
    • ProviderSessionReaper is 30-minute inactivity, skips activeTurnId and background liveness, and is not tied to session.started on another thread
    • auto-settlement skips session.status === "starting" | "running"; status:compacting is mapped to runtime waiting, which ingestion stores as running, so a compacting turn should not auto-settle
    • the closed slot-reservation work in feat(server): reserve shared slots for concurrent provider work #10097 never landed and rejected new work rather than evicting live sessions

    status:compacting heartbeats also do not bump ProviderSessionDirectory.lastSeenAt (only binding upserts do). That matches the “looks idle” smell, but the reaper still should spare a compacting slash-command turn because activeTurnId stays set (waiting is allowed to keep an active turn).

    The 38s gap after B’s session.started also 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 inside startSession.

    Compaction shape

    Claude compaction is a slash-command turn (compaction: { type: "slash-command", command: "/compact" }), not native compactThread. While it runs, Claude emits system/status with status: "compacting" every ~30s; the adapter forwards that as session.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:

    1. ~/.t3/userdata/logs/provider/events.<threadA>.log (already captured)
    2. orchestration / server log for thread.session-stop-requested, thread.turn-interrupt-requested, and provider.session.reaped

    Procedure: /compact on a large Claude thread until status:compacting, then start a session on a different thread (new thread activate is enough). Repeat once.

    • If A dies again, the domain event names the caller; compacting sessions should be excluded from that path, and the stop reason should name it.
    • If A survives, treat the original as an unattributed explicit stop (MCP/client/other) plus the generic reason gap — still worth fixing the reason string so the next hit is attributable.

    Do not flip emitExitEvent on 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.

  2. added
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions