Skip to content

session_state_changed: idle carries no turn attribution — owed-trailing-idle debt can absorb a turn's own final idle and park the prompt #1144

Description

@Taeyeon1607

Summary

session_state_changed: idle is the authoritative turn-over signal but carries no turn attribution, so the adapter compensates with a counter (session.owedTrailingIdles) that is deliberately biased to over-count. When the idle that the counter absorbs happens to be a turn's own final idle, nothing further arrives and session/prompt never resolves.

Since result frames already carry user_message_uuid (#1065), stamping the same field on session_state_changed would let the adapter match instead of count, and the counter could be deleted.

Versions

  • @agentclientprotocol/claude-agent-acp 0.79.0
  • @anthropic-ai/claude-agent-sdk 0.3.275
  • Claude Code CLI 2.1.275

We run the published adapter with a few local logging-only patches; the debt logic quoted below is stock.

Symptom

A prompt never resolves. The agent has clearly finished — no further output, no open tool calls — but session/prompt does not return and the session stays running. Our ACP client observes inflight=1, openToolCalls=0, and no further session/update for 10+ minutes, until our own force-cancel floor fires and releases it.

Seen twice on the same client, eleven days apart. In both incidents the state recorded at the last idle was owed=1, held=false, steering=false, and then silence. One of the two parked prompts was itself a /compact.

Mechanism

The idle frame has no attribution field:

export declare type SDKSessionStateChangedMessage = {
    type: 'system';
    subtype: 'session_state_changed';
    state: 'idle' | 'running' | 'requires_action';
    uuid: UUID;           // this frame's own id
    session_id: string;
};

uuid identifies the frame, not the turn it ends. So a late trailing idle belonging to an already-settled turn is indistinguishable from the idle that ends the current turn. owedTrailingIdles (incremented at five sites in acp-agent) stands in for that missing information, and is consumed in the idle lane ahead of every settle lane:

else if (session.owedTrailingIdles > 0) {
    // Absorb a settled turn's trailing idle. ...
    session.owedTrailingIdles--;
}

The bias is intentional and documented in the source:

over-counting is benign (absorbs one future idle) while under-counting risks the false fail this debt exists to prevent.

Harmless if it never comes: the debt absorbs one future idle.

"Absorbs one future idle" is harmless only if another idle follows. When the absorbed idle is the turn's own last one, none does, and the prompt parks.

Why the 0.79.0 sweep does not cover this

0.79.0 clears unpaid debt on the transition into running:

Debt still outstanding here can never be paid. ... Sweep it.

That stops the debt leaking into the next turn, which is valuable. It cannot rescue the turn whose idle was already absorbed: the next running transition requires a new prompt, and the parked prompt is the one that would have to resolve first.

Proposed fix — attribution instead of counting

Result frames carry user_message_uuid and user_message_uuids (up to 64 entries, per its doc comment) since #1065, and the adapter already binds results to turns with it.

If session_state_changed carried the same field(s):

  • idle stamped with an already-settled turn → ignore it,
  • idle stamped with the active turn → settle it.

No absorbing, no counter, and #825's false-fail is prevented by the match itself rather than by a heuristic tuned against it. Because one idle can span several prompts the host merged into a single turn, the plural user_message_uuids is probably the right field to stamp.

Reproduction

We do not have a deterministic minimal reproduction, and both incidents were on long-lived production sessions, so I would rather not attach raw traces. What I can share is the state-transition summary around each incident (frame order, state, and the adapter's debt/held/steering values) — happy to post those, or to test a preview build that stamps the field.

The shape in both cases: a turn whose results took an interrupt or background-agent path that incremented the debt, followed by a turn whose ending idle was the next idle to arrive. Both were on a session that receives frequent mid-turn steering.

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions