MCP session tools: a turn killed by the usage limit is indistinguishable from a clean finish (no terminal-state field; lastActivityAt advances)

Status Open
Maintainer reply None cached
Activity 0 comments · opened Aug 14, 2026

Summary

When a session's turn is killed by the usage limit, the MCP session tools report the session as though it finished normally. There is no field distinguishing completed from interrupted, and lastActivityAt moves forward when the turn dies — so a session that silently dropped an instruction is indistinguishable from one that carried it out.

The desktop app clearly knows the difference: it draws a ⚠ badge on those sessions in the sidebar. That information is not on the MCP surface.

Related: #82728 (scheduled one-shots killed mid-tool-call and recorded as successful) and #78558. Same root cause — usage-limit termination reported as success — but a different surface. This one is about mcp__ccd_session_mgmt__*.

Reproduction

Multi-session setup on one machine (~11 concurrent sessions, all Opus, effort high), so the shared usage pool is under pressure.

  1. Session A sends session B a work instruction via mcp__ccd_session_mgmt__send_message. B is confirmed isRunning: true beforehand.
  2. The message is delivered — it renders in B's transcript as a user turn.
  3. B begins a turn, and the turn dies at the usage limit ("Approaching extra usage limit" banner).
  4. B never performs the instruction. In the sidebar B appears under Completed with a .
  5. get_session(B) reports nothing wrong.

Observed twice simultaneously. In one case the instruction was an explicit authorization to push a branch and open a PR; an hour later the branch still had no ref on the remote, so ~140 lines of committed work existed only in that one working directory.

What get_session returns

isRunning, isArchived, lastActivityAt, createdAt, model, effort, cwd, originCwd,
branch, sourceBranch, worktreePath, worktreeName, isRemote, title, sessionId

No error, no exit reason, no "last turn terminated without completing", no "a delivered message was never processed".

And lastActivityAt advances when the turn dies — it read several minutes after the message arrived — so the most natural liveness heuristic (isRunning: false + a recent lastActivityAt ⇒ it did the work) is exactly backwards.

To be precise about what is and isn't claimed: the session is not necessarily dead. One of the two took another turn ~20 minutes later. The problem is narrower and worse — a session that later recovers does not retroactively execute the instruction it was given. The turn carrying it is gone and nothing re-delivers it.

Why a caller cannot work around this locally

Both obvious workarounds are dead ends, and both were measured:

Usage state isn't readable. ~/.claude/policy-limits.json contains org policy (allow_remote_control, compliance taints), not quota. Grepping usage_limit|rate_limit|extra usage|resets_at|utilization across ~/.claude/**.json (excluding projects/) returns nothing. So a caller can't pre-flight "is this target about to be capped".

Session ids don't join to the process registry. ~/.claude/sessions/<pid>.json carries {pid, sessionId, cwd, messagingSocketPath, …} — but that sessionId is a different id-space from what list_sessions returns. The same working directory appeared as 60cf4e19-… in the registry and local_83cc5e95-… in the session list. So a liveness guard can only join on cwd, which is unreliable (directories get recycled between sessions).

That leaves no way to tell, from any local source, whether a delivered message was acted on.

Requested fix

Any one of these, in preference order:

  1. A terminal-state field on get_session / list_sessions — e.g. lastTurnOutcome: "completed" | "interrupted" | "usage_limited" | "error". This is the whole fix; everything below is a consolation prize. The desktop UI already has whatever backs the ⚠.
  2. A flag for delivered-but-unprocessed input, so a sender can distinguish "it read this" from "this is queued and may never be reached".
  3. Make send_message's result honest about the target's state. It currently reports delivery "if that session stays healthy" — a conditional that reads as a confirmation, and was read that way.

Impact

Any orchestration built on send_message + list_sessions silently loses work, and every observable agrees with the sender: the send succeeds, the message visibly renders in the target, and the session metadata looks like a clean finish. On the machine above, a sweep found 12 working directories holding 28 commits that existed on no remote — accumulated from exactly this failure mode going unnoticed.

The workaround we settled on is to stop trying to detect it: instructions now put "push whatever you have" as step 1 rather than the last line, and a scheduled job backs up any working directory holding unpushed commits. That works, but it is compensating for a missing field.

View original on GitHub ↗