feat(claudecode): hold working while async agents/workflows are pending #161

Merged
dominik.polakovics merged 1 commit from afk/159 into main 2026-07-13 20:44:54 +02:00

Closes #159

What

The claude-code adapter now derives working — not needs_input — when the model ends its turn while async background work it spawned (Agent tool launches, Workflows) is still running, so the push gate never fires a spurious "needs input" notification mid-run. The CLI re-invokes the model when the work completes; only the run's real turn-end pushes.

How

Truth source is structural harness markers, never message prose (as grilled in the issue):

  • Pending set in foldTranscript (internal/provider/claudecode/chat.go): the top-level toolUseResult.status == "async_launched" on a launch's tool_result event adds agentId (Agent) / taskId (Workflow — the id notifications actually reference, never the wf_… runId). The same gate structurally excludes background Bash (backgroundTaskId, no status — a dev server never exits and would pin working forever), Monitor, and synchronous Agent calls (status:"completed").
  • Removal on terminal <task-notification> payloads (<task-id> + any non-empty <status>: completed/failed/killed) via all three real carriers — queue-operation content, the standalone user message form, and the mid-turn attachment (commandMode:"task-notification"). Status-less Monitor <event> payloads never remove. Removal is idempotent (duplicate notifications per id are real). TaskStop removes by input.task_id; SendMessage's resumedAgentId re-adds (messaging a stopped agent live-verifiably resumes it and re-notifies on the same id).
  • deriveState: assistant:text + pending non-empty → working; every notification carrier itself derives working ("about to be re-invoked", closing the enqueue→delivery window); API errors stay needs_input unconditionally; the structured break-through (live dialog spool → question, blocked marker → needs_input) is untouched and pinned by a new ReadChat test.
  • No notify-gate changes, no new API surface, no rendering/seq changes; codex untouched (native lifecycle events).

Fixture spike (plan step 1)

No async fixtures existed. Marker shapes were pinned by live probes on the installed CLI 2.1.206 (async Agent launch, Workflow, background Bash, TaskStop→killed notification, SendMessage resume with duplicate notification) and cross-verified against wild transcripts from real lab sessions on 2.1.198/2.1.204 (~1,440 status-bearing notifications surveyed; shapes identical across versions, delivery form varies by session state only).

Tests

  • claudecode: table-driven state edges + cumulative-prefix trajectory tests (launch→text holds; notification→resume→final text = one real needs_input; Bash never holds; sync Agent never holds; failed/killed release; status-less payloads don't; TaskStop releases; SendMessage re-arms; error outranks the hold; spool dialog/blocked marker break through).
  • compat: three captured roundtrip fixtures (transcript-async{agent,workflow,bash}-live-*.jsonl) + TestCompat_AsyncTaskFixtures as the version-drift canary; compat.md §5 gained the full coupling subsection (marker shapes, carriers, ordering hazards, re-verify instructions).
  • End-to-end sanity: replaying this very AFK session's real 365-line transcript through the parser: pre-change 12 needs_input transitions (9 spurious, during pending windows), post-change 3 — all genuine turn-ends.
  • go vet, gofmt, go build, full go test ./... green (only the pre-existing sandbox-environmental internal/tmuxx integration failures, identical on a clean tree).

🤖 Generated with Claude Code

https://claude.ai/code/session_011LF7n7JiCQichBw3ZbZyae

Closes #159 ## What The claude-code adapter now derives `working` — not `needs_input` — when the model ends its turn while async background work it spawned (Agent tool launches, Workflows) is still running, so the push gate never fires a spurious "needs input" notification mid-run. The CLI re-invokes the model when the work completes; only the run's real turn-end pushes. ## How **Truth source is structural harness markers, never message prose** (as grilled in the issue): - **Pending set in `foldTranscript`** (`internal/provider/claudecode/chat.go`): the top-level `toolUseResult.status == "async_launched"` on a launch's tool_result event adds `agentId` (Agent) / `taskId` (Workflow — the id notifications actually reference, never the `wf_…` runId). The same gate structurally excludes background Bash (`backgroundTaskId`, no status — a dev server never exits and would pin `working` forever), Monitor, and synchronous Agent calls (`status:"completed"`). - **Removal** on terminal `<task-notification>` payloads (`<task-id>` + any non-empty `<status>`: completed/failed/killed) via all three real carriers — `queue-operation` content, the standalone user message form, and the mid-turn `attachment` (`commandMode:"task-notification"`). Status-less Monitor `<event>` payloads never remove. Removal is idempotent (duplicate notifications per id are real). `TaskStop` removes by `input.task_id`; SendMessage's `resumedAgentId` re-adds (messaging a stopped agent live-verifiably resumes it and re-notifies on the same id). - **`deriveState`**: `assistant:text` + pending non-empty → `working`; every notification carrier itself derives `working` ("about to be re-invoked", closing the enqueue→delivery window); API errors stay `needs_input` unconditionally; the structured break-through (live dialog spool → `question`, blocked marker → `needs_input`) is untouched and pinned by a new ReadChat test. - No notify-gate changes, no new API surface, no rendering/seq changes; codex untouched (native lifecycle events). ## Fixture spike (plan step 1) No async fixtures existed. Marker shapes were pinned by live probes on the installed CLI 2.1.206 (async Agent launch, Workflow, background Bash, TaskStop→killed notification, SendMessage resume with duplicate notification) and cross-verified against wild transcripts from real lab sessions on 2.1.198/2.1.204 (~1,440 status-bearing notifications surveyed; shapes identical across versions, delivery form varies by session state only). ## Tests - `claudecode`: table-driven state edges + cumulative-prefix trajectory tests (launch→text holds; notification→resume→final text = one real needs_input; Bash never holds; sync Agent never holds; failed/killed release; status-less payloads don't; TaskStop releases; SendMessage re-arms; error outranks the hold; spool dialog/blocked marker break through). - `compat`: three captured roundtrip fixtures (`transcript-async{agent,workflow,bash}-live-*.jsonl`) + `TestCompat_AsyncTaskFixtures` as the version-drift canary; compat.md §5 gained the full coupling subsection (marker shapes, carriers, ordering hazards, re-verify instructions). - End-to-end sanity: replaying this very AFK session's real 365-line transcript through the parser: pre-change 12 needs_input transitions (9 spurious, during pending windows), post-change 3 — all genuine turn-ends. - `go vet`, `gofmt`, `go build`, full `go test ./...` green (only the pre-existing sandbox-environmental `internal/tmuxx` integration failures, identical on a clean tree). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_011LF7n7JiCQichBw3ZbZyae
feat(claudecode): hold working while async agents/workflows are pending
All checks were successful
ci / native (pull_request) Successful in 5m12s
4490473e27
Track a pending-work set in the transcript fold (issue #159): async
Agent/Workflow launches add their id from the structural toolUseResult
marker (status "async_launched"; agentId for Agent, taskId for
Workflow), terminal task-notifications remove it via any of the three
carriers (queue-operation content, standalone user message, mid-turn
attachment), TaskStop removes by input.task_id, and SendMessage's
resumedAgentId re-adds a resumed agent. While the set is non-empty an
assistant-text tail derives `working` instead of `needs_input`, so no
spurious needs-input push fires at turn end; every carrier also derives
`working` (the CLI is about to re-invoke the model), closing the
enqueue-to-delivery window. API errors still surface as needs_input,
and the structured live signals (dialog spool, blocked marker) keep
outranking the hold. Background Bash (backgroundTaskId, no status) is
excluded by construction — a dev server never exits and would pin
`working` forever.

Marker shapes mined live from 2.1.198-2.1.206 transcripts; captured
roundtrip fixtures pin them in compat testdata as the drift canary
(TestCompat_AsyncTaskFixtures), documented in compat.md §5.

Closes #159

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF7n7JiCQichBw3ZbZyae
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab!161
No description provided.