Skip to content

fix: forward task_started / task_notification between turns via a background drain#713

Open
julianmesa-gitkraken wants to merge 2 commits into
agentclientprotocol:mainfrom
julianmesa-gitkraken:fix/336-background-drain-task-notifications
Open

fix: forward task_started / task_notification between turns via a background drain#713
julianmesa-gitkraken wants to merge 2 commits into
agentclientprotocol:mainfrom
julianmesa-gitkraken:fix/336-background-drain-task-notifications

Conversation

@julianmesa-gitkraken
Copy link
Copy Markdown

@julianmesa-gitkraken julianmesa-gitkraken commented May 26, 2026

Fixes #336.

Summary

When Claude Code launches background tasks via the Task tool with run_in_background=true, task_notification (and task_started) system messages arriving between turns sit in the SDK's internal buffer until the user sends the next message — at which point the wrapper's prompt() switch hits the // Todo: process via status api no-op and silently discards them. The user observes this as "the agent doesn't react to my background task completing — I have to type something to wake it, and then it acts on a stale prompt".

The wrapper now runs a single-consumer session reader per session that is the sole consumer of session.query. It forwards task_started / task_notification to the ACP client the moment they arrive (in-turn or between turns), hands in-turn messages to the active prompt() via a queue, and forwards autonomous task-notification followups out-of-turn. prompt() no longer touches the SDK iterator directly.

An earlier revision of this PR implemented the issue's "Option B" sketch as a background drain that shared the SDK AsyncGenerator with prompt() via a cooperative lock (drainedBuffer / drainReadInFlight / onPromptIdle). An adversarial review found that sharing one AsyncGenerator between two consumers is fragile by construction (≈15 findings: orphaned Promise.race reads, stale buffered messages leaking into the next turn, client-latency coupling, missing abort on process-death, etc.). The drain was replaced with the single-consumer design described below.

Architecture

A new module src/session-reader.ts holds three unit-testable building blocks:

  • TurnQueue — single-producer / single-consumer async queue. The reader is the only producer; prompt() is the only consumer per turn. close() / error() propagate to the consumer; clear() drops leftovers for an abandoned turn.
  • classifyOffTurn — pure classifier: a message is either lifecycle (emit immediately) or a followup-candidate (feed to the collector).
  • OffTurnFollowupCollector — small state machine for messages seen while no turn is active. It buffers followup candidates until the closing result/idle, then forwards them (when result.origin.kind === "task-notification") or discards them as aftermath. Bounded by a 256-entry cap.

In src/acp-agent.ts:

  • #runSessionReader — the only caller of session.query.next(). Per message: optionally schedule a raw-SDK emit; if lifecycle, emit it; else if a turn is running, push to turnQueue; else feed offTurn. Exits cleanly on abort / iterator close / iterator throw, closing the queue so any consumer is unblocked.
  • #enqueueReaderSideEffect — serializes the reader's client-facing emits (raw SDK messages, lifecycle, followup) onto a per-session promise chain the reader never awaits, so a slow ACP client can't stall SDK consumption or block the next prompt.
  • #emitTaskLifecycleUpdate — renders task_started (using the SDK's description) and task_notification (using the SDK's real status) as an agent_message_chunk, routed to the reader's bound sessionId.
  • #emitFollowup / computeFollowupUsageUpdate — forward an autonomous followup's content using the same notification helpers prompt() uses, plus a usage_update derived from the followup's own snapshots, without ever touching accumulatedUsage, stopReason, or the user's pending prompts.
  • prompt() consumes session.turnQueue.take() instead of session.query.next(). Raw-SDK emit moved entirely to the reader (no double-emit). Error/cancel paths clear the queue.

Behaviour

  • task_started / task_notification produce a short bracketed agent_message_chunk[task <id>] started: <description> or [task <id>] <status>: <summary> plus output: <path> when present — whether the event arrives during a turn or between turns.
  • skip_transcript: true lifecycle messages produce no client-visible update.
  • Autonomous task-notification followups (an SDK-driven mini-turn that runs between user turns) are forwarded to the client out-of-turn: their assistant/tool content via the normal notification helpers, plus a usage_update carrying the followup's cost and _claude/origin meta. They never contaminate the next user turn's usage or stop reason.
  • Non-followup off-turn aftermath (e.g. the tail of a cancelled or errored turn) is discarded with a debug log, never replayed into the next prompt.

Lifecycle & robustness

  • Single consumer. Only the reader calls session.query.next(), eliminating the concurrent-next()-on-an-AsyncGenerator hazard.
  • Teardown. teardownSession and the process-died recovery abort(), then await readerDone, then drain the detached readerSideEffects chain (bounded by a 2s timeout) before deleting the session — so queued emits finish before the session disappears and a wedged client can't hang teardown.
  • Cancellation. prompt() clears the turnQueue on both error and cancel, so messages the reader buffered for an abandoned turn don't leak into the next one.
  • Raw-SDK ordering contract. Raw _claude/sdkMessage emits are FIFO among themselves but are explicitly not ordered relative to the derived session/update stream (the reader doesn't block on the client). Documented at the emit site.

Tests

npm run test:run: 355 passed | 13 skipped.

  • src/tests/session-reader.test.ts — unit tests for TurnQueue (push/take/close/error/clear, FIFO, concurrent-take guard), classifyOffTurn (every subtype), OffTurnFollowupCollector (all transitions, cap, emit-error swallowing).
  • src/tests/acp-agent.test.tsdescribe("session reader (issue #336)") — lifecycle (description not summary; real status; skip_transcript), raw-SDK emit (off-turn, no in-turn double-emit, FIFO order), followup forwarding (out-of-turn, no usage contamination, no authRequired from a stale Please run /login, lifecycle-mid-followup, real #emitFollowup end-to-end render), aftermath (orphan+idle, non-followup result, cancellation aftermath discarded), process-died propagation, teardown awaits reader + drains side-effects, single-consumer invariant.
  • computeFollowupUsageUpdate unit cases: buffered snapshot, fallback to result.usage, subagent-only fallback, model with no matching modelUsage key → inferred context window.

Verification

npm run build         # tsc, clean
npm run check         # eslint + prettier, clean
npm run test:run      # 355 passed | 13 skipped

Out of scope (deliberately not in this PR)

Two product enhancements were considered and intentionally left out:

  1. Live streaming of autonomous followups. Today a followup's content is buffered and forwarded as a block when its closing result arrives. Streaming it token-by-token is blocked by the SDK contract, not by effort: only the result message carries origin; the assistant / stream_event content messages do not. To stream we'd have to emit content before knowing whether the group is a real followup or the aftermath of a cancelled/errored turn — and emitting aftermath optimistically reintroduces exactly the contamination this PR fixes, with no way to retract it over ACP. A safe implementation needs either (a) the SDK tagging followup content messages with origin (not just the result), or (b) a retractable preview channel in the ACP protocol. Neither exists today.

  2. A dedicated session-update variant / _meta marker for background-task output. Followup and lifecycle updates currently piggyback on agent_message_chunk (the followup's usage_update already carries _claude/origin). A richer scheme — tagging every followup chunk with _meta or a dedicated task_* session-update so clients can render background output distinctly — is additive and backward-compatible, but only produces visible effect once an ACP client consumes it. Left for a follow-up coordinated with client-side rendering rather than shipped speculatively here.

@julianmesa-gitkraken julianmesa-gitkraken marked this pull request as draft May 26, 2026 17:04
@julianmesa-gitkraken julianmesa-gitkraken force-pushed the fix/336-background-drain-task-notifications branch 3 times, most recently from 52eac3f to 7ff658b Compare May 27, 2026 08:27
…nFollowupCollector

Building blocks for the single-consumer session reader (issue agentclientprotocol#336), in a
standalone module so each unit is testable in isolation:

- TurnQueue: single-producer / single-consumer async queue. The reader is
  the sole producer; prompt() is the sole consumer per turn. close() and
  error() propagate to the consumer; clear() drops leftovers for an
  abandoned turn; take() guards against concurrent consumers.
- classifyOffTurn: pure classification of an off-turn message into a
  task-lifecycle event (emit immediately) or a followup candidate.
- OffTurnFollowupCollector: state machine that buffers off-turn followup
  candidates until the closing result/idle, then forwards them (for
  origin.kind === "task-notification") or discards them as aftermath.
  Bounded by a 256-entry cap.
@julianmesa-gitkraken julianmesa-gitkraken force-pushed the fix/336-background-drain-task-notifications branch from 9ab68ec to 6eab829 Compare May 27, 2026 09:29
…der (agentclientprotocol#336)

Background tasks launched with run_in_background=true emit task_started /
task_notification system messages between turns. Previously they sat in the
SDK's buffer until the next user prompt, where prompt()'s switch discarded
them as a no-op — the agent appeared unresponsive to background-task
completion and then acted on a stale prompt.

A per-session reader (#runSessionReader) is now the sole consumer of
session.query.next(). It forwards lifecycle events to the client
immediately (in-turn or between turns), hands in-turn messages to the active
prompt() via a TurnQueue, and forwards autonomous task-notification
followups out-of-turn. prompt() consumes the queue instead of touching the
iterator, so there is only ever one consumer of the AsyncGenerator.

Key pieces:
- #runSessionReader: single linear loop; lifecycle/raw/followup emits are
  scheduled on a detached, never-awaited readerSideEffects chain so a slow
  ACP client can't stall SDK consumption or block the next prompt.
- #emitTaskLifecycleUpdate: renders task_started (SDK description) and
  task_notification (real SDK status, no fallback) as an agent_message_chunk
  routed to the reader's bound sessionId.
- #emitFollowup + computeFollowupUsageUpdate: forward a followup's content
  via the same notification helpers prompt() uses, plus a usage_update
  derived from the followup's own snapshots, without touching
  accumulatedUsage / stopReason / pending prompts.
- prompt() consumes turnQueue.take(); raw-SDK emit moved entirely to the
  reader (no double-emit); the error/cancel finally clears the queue so an
  abandoned turn's buffered messages don't leak into the next one.
- teardownSession and process-died recovery abort, await readerDone, then
  drain readerSideEffects (bounded by a 2s timeout) before deleting the
  session, so queued emits finish first and a wedged client can't hang
  teardown.

Tests: session-reader describe block covers lifecycle (description vs
summary, real status, skip_transcript), raw-SDK emit (off-turn, no in-turn
double-emit, FIFO order), followup forwarding (out-of-turn, no usage
contamination, no authRequired from a stale login result, lifecycle
mid-followup, real #emitFollowup end-to-end), aftermath discard,
process-died propagation, teardown drain, and the single-consumer
invariant. computeFollowupUsageUpdate has unit coverage for buffered
snapshot, result.usage fallback, subagent-only fallback, and inferred
context window. acp-agent-settings mocks gain next/close stubs so the
reader parks cleanly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Background task notifications (task_notification / task_started) are silently dropped causing desynced conversations

1 participant