claude adapter: hold working state while async agents/workflows are pending (stop spurious needs-input pushes) #159
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/coding-lab#159
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
When the model launches an async agent (Agent tool) or a workflow and ends its turn with text like "launched an agent, waiting on it", the last transcript event is
assistant:text, soderiveState(internal/provider/claudecode/chat.go:391-417) returnsneeds_inputand the push gate (internal/chat/notify.go) fires a notification — even though nothing is needed from the operator and the CLI will re-invoke the model when the background work completes. Synchronous tools holdworkingfine (their tool_result is outstanding); async launches return their tool_result immediately, so nothing holds the state.Design (grilled 2026-07-13)
workingwhenever background work the session spawned is still running.foldTranscript(chat.go:228-327). Maintain a pending set:<task-notification>block with a matching task-id — regardless of status (completed/failed/killed all mean "will not re-invoke for this id" / "model was re-invoked"). Removal must be idempotent (the same id may notify more than once). ATaskStoptool call for the id also removes it. Session rotation (/clear, /rewind) resets naturally because folding restarts on the new transcript.SendMessageto a completed agent re-activate it? If yes, a SendMessage tool_use targeting a known agent id re-adds it to the pending set.run_in_background) is explicitly NOT counted — a dev server never exits and would pin the session atworkingforever (suppressing pushes, 409-ing /pull-base, making the clear-hook skip its pull). Add a test asserting a background-Bash launch does not hold.deriveStatechange:lastKey == "assistant:text"&& pending set non-empty →StateWorkinginstead ofStateNeedsInput.lastKey == "error"staysneeds_inputeven with pending work (errors must surface).question+ push; Notification-hook blocked marker (permission prompt) →needs_input+ push. Plain-text questions the model asks while agents are pending stay silent atworking— accepted: the turn resumes when the agents finish and the final turn-end pushes as usual.workingthrough the pending window means no episode ever arms. The final turn-end (pending set empty) pushes exactly as today.workingbadge, no new API surface. Enrichment ("working, 2 agents") deferred.Plan
<task-notification>completion, Workflow run, background Bash launch/exit, TaskStop, SendMessage resume of a completed agent, duplicate notifications for one id. These become testdata fixtures — none exist today.foldTranscript+ thederiveStatehold, matching the marker shapes the fixtures establish.Acceptance
working, zero push.workingpast turn-end.Landing audit — PR #161
Verdict: PASS.
Verification signal relied on: Forgejo Actions
ci / native— success (5m12s, run 175). Not re-run. Theci-nixgate did not run, and correctly so: it is path-gated to**/*.nix/flake.lock/go.mod/go.sum, and this diff touches none of them (8 files, all Go + testdata + compat docs). The native gate exercised the whole change.Merge state:
afk/159is up to date withorigin/main(no conflict, no resolution commit — the green CI is over the code as it will merge).Conventions: title is Conventional Commits (
feat(claudecode): …); body carries a workingCloses #159; head branchafk/159matches the AFK contract.Diff review against this issues design: implementation matches the grilled design point-for-point — pending set in
foldTranscriptkeyed on structuraltoolUseResultmarkers (never prose),deriveStatesoftening only theassistant:textedge, API errors still unconditionallyneeds_input, ReadChats live-signal precedence untouched and pinned by a new test. Verified in the captured fixtures that the add/exclude gate is genuinely structural: the async Agent launch result carriesstatus:"async_launched"+agentId, while background Bash carriesbackgroundTaskIdand no status — so decision 3 (Bash never holds) holds by construction, not by name-matching.One deviation from the issue text, and it is an improvement: decision 2 said remove on a
<task-notification>"regardless of status". The implementation requires a non-empty<status>to remove, because the spike found Monitor emits status-less interim<event>payloads through the same carrier — removing on those would release the hold early. Stricter, spike-backed, documented in compat §5.Residual (accepted by design, not a blocker): a pending id that never receives a terminal notification — an agent whose process dies without notifying — pins the run at
workingand suppresses its pushes for the rest of the session. That is the inverse failure of the bug being fixed, and it is inherent to decision 5s structured-only break-through: a live dialog or a blocked marker still breaks through, but plain turn-end no longer does while pending is non-empty. Worth watching in the wild; if it bites, the fix is a liveness backstop, not a change to this fold.