Skip to content

feat(server,web): show background work that is still running, pinned above the composer (#604) - #807

Merged
edspencer merged 1 commit into
mainfrom
feat/604-background-work-registry
Aug 10, 2026
Merged

feat(server,web): show background work that is still running, pinned above the composer (#604)#807
edspencer merged 1 commit into
mainfrom
feat/604-background-work-registry

Conversation

@edspencer

@edspencer edspencer commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Closes #604. Design and rejected alternatives: #604 (comment)

The problem

A sub-agent gets a live row above the composer while it works. Nothing else does. A background Bash, a Monitor, or a workflow can run for minutes behind a card scrolled far up the transcript, and the only hint is a static running chip that means "no completion notification was found in the transcript" — not "we checked". A killed task keeps that chip forever.

And the chat claims to be idle while it happens. chat:active reports one hub turn; a background task outlives the turn that launched it. So the moment the reply lands, every consumer of that signal — sidebar streaming dot, Home in-flight badge, running-only filter, the composer's own state — is told the session stopped while minutes of work continue. That is #604.

Where the signal was

The Agent SDK already publishes exactly this, and we were throwing it away. background_tasks_changed carries the complete live set on every membership change, with task_started / task_progress / task_notification / task_updated adding per-task detail; BackgroundTaskSummary already enumerates shell | subagent | monitor | workflow. herdctl taps that stream to decide session reaping (session-hooks.js:137). Paddock's entire consumption was four comments and no code.

Meanwhile #750 spent real effort reconstructing one field of it from disk — scraping each sub-agent transcript for an end_turn marker with a ten-minute stale backstop — which task_notification reports directly, and which covers only sub-agents.

What this does

BackgroundRegistry (background-live.ts) — a per-session live set fed from raw SDK messages.

chat:background frame — broadcast on change, replayed on connect. That replay is the whole rehydration story: no polling, no arming heuristic. A pane that mounts mid-run is populated on the first paint.

chat:active now tells the truthrunning is turn.running || background.isBusy(session).

RunningWork replaces RunningSubagents in the same slot. Sub-agents keep coming from the existing transcript-derived path (richer per-step detail, already tested); shells, monitors and workflows come from the registry. A registry subagent row already shown by the transcript path is dropped so the two sources cannot double-render; one the transcript path has not found still appears. Ambient work flagged skip_transcript is hidden.

The signal split — please review this bit

Making running true whenever background work exists is what the issue asks for, and my first working build did exactly that. QA showed the cost immediately: after the turn had finished, the working indicator still animated and the composer was locked to "Queue a message to send next…". An hour-long Monitor would have made the chat unusable while telling the user the model was thinking.

That is the overloaded-running problem — one boolean answering "is a turn in flight?", "is this chat busy?" and "should the composer lock?".

So the frame now carries turnRunning alongside running:

consumer reads why
sidebar dot, fleet strip, Home badge, running-only filter running background work genuinely means busy
composer lock, working indicator turnRunning a Monitor is not the model thinking

turnRunning is optional on the wire; an older client falls back to running and behaves as before. This is a partial, deliberately minimal move toward the separation — it does not restructure the state machine.

Verification

32 new tests, plus a new [[BGTASK]] fake-claude directive so all of this is exercised with no API spend.

  • 12 unit (background-live.test.ts) — replace semantics, edge-cannot-create, enrichment surviving the next level, terminal task_updated, startedAt not restamped, unknown task types kept.
  • 17 component (RunningWork.test.tsx) — all four prior sub-agent behaviours preserved, plus the new rows, dedup, skip_transcript, elapsed, and inertness without a toolUseId.
  • 3 integration (ws-background.test.ts) — the frame, edge enrichment, REPLACE shrinking the set, chat:active staying true past chat:complete, and connect-time replay to a socket that joins mid-run.

The #604 guard was verified to fail without the fix. Reverting the chat:active join makes keeps chat:active running:true while background work is in flight fail while the other two still pass.

Manually QA'd on a pm instance driven through Playwright (batch drive mode, fake claude, tokens unset, isolated HOME — no API spend, no access to real transcripts). States confirmed in the live UI:

state observed
mid-turn 2 THINGS RUNNINGshell wait for CI 0:06, monitor errors in deploy.log 0:06
after chat:complete 1 THING RUNNING — shell gone via REPLACE, monitor still ticking; fleet strip 1 RUNNING; composer usable
after full page reload bar repopulated from the connect replay at 0:52 — elapsed continuous, so startedAt survives
light + dark tokens resolve correctly in both; info reads distinctly from accent

Scope and caveats

  • Session drive mode only. The CLI runtime reads the transcript file; these are stream-only control messages and never appear there. Batch mode is unaffected, not broken.
  • Empty after a server restart. The level signal is per-process and emits nothing at startup. This is correct rather than a gap — Paddock stops the fleet with waitForJobs: false, so those tasks are dead. The old chip went on claiming a killed task was alive.
  • Per-row Stop is not in this PR. fix(server): make Stop work while a chat runs background work (#528) #662 made Stop work during the background phase, but at whole-session granularity (reapChatSession). Wiring TaskStop per row would let you kill one runaway monitor while sub-agents keep working — a good follow-up.
  • RunningSubagents is deleted, and the E2E testid running-subagents is renamed to running-work.

…above the composer (#604)

A sub-agent already got a live row above the composer while it worked. Nothing
else did. A background `Bash`, a `Monitor` or a workflow could run for minutes
behind a card scrolled far up the transcript, and the only hint was a static
`running` chip that meant "no completion notification was found in the
transcript" — not "we checked". A killed task kept that chip forever.

And the chat claimed to be idle while it happened. `chat:active` reports one hub
turn, and a background task outlives the turn that launched it, so the moment the
reply landed every consumer of that signal — the sidebar dot, the Home in-flight
badge, the running-only filter, the composer — was told the session had stopped
while the work continued. That is the defect this closes.

The signal already existed and we were dropping it. The SDK publishes
`background_tasks_changed` (the complete live set on every membership change)
plus `task_started` / `task_progress` / `task_notification` / `task_updated` for
per-task detail, and `BackgroundTaskSummary` already enumerates
shell | subagent | monitor | workflow. herdctl taps that stream to decide
reaping; Paddock's consumption was four comments and no code.

- `BackgroundRegistry` folds those messages into a per-session live set, fed from
  all five turn paths. The level signal is the sole authority on membership and
  edges may only enrich, so a missed edge cannot wedge a stale row — the #528
  failure mode. Terminal edges evict ahead of the confirming level.
- `chat:background` broadcasts the set and is replayed on connect, so a remount
  is populated on the first paint rather than after a poll.
- `chat:active.running` now accounts for background work.
- `RunningWork` replaces `RunningSubagents`, rendering shells, monitors and
  workflows alongside sub-agents. A sub-agent the transcript path already shows
  is not duplicated; `skip_transcript` work is hidden.

`chat:active` also gains `turnRunning`. Making `running` true whenever background
work exists is what the issue asks for, but QA showed the cost: after the turn
finished, the working indicator still animated and the composer stayed locked to
"Queue a message to send next…". An hour-long Monitor would make the chat
unusable while claiming the model was thinking. Status readouts now read
`running`; the composer lock and working indicator read `turnRunning`. Optional
on the wire, so an older client falls back and behaves as before.

Session drive mode only — the CLI runtime reads the transcript file, which these
stream-only control messages never reach. A new `[[BGTASK]]` fake-claude
directive drives the whole path with no API spend.

Co-Authored-By: Claude <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying paddock with  Cloudflare Pages  Cloudflare Pages

Latest commit: d8a3c72
Status: ✅  Deploy successful!
Preview URL: https://150a6027.paddock-7u2.pages.dev
Branch Preview URL: https://feat-604-background-work-reg.paddock-7u2.pages.dev

View logs

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.

chat:active reports running:false while background sub-agents are still in flight

1 participant