fix: a handoff to a worker reads as done, and work an agent waits on shows an hourglass, not a live turn's spinner

Merged
ST
1d ago
+415-40

Summary

A session whose agent handed its work to a separate worker ("I've started a worker to fix it, so you can close this session… I turned off its notifications to this session") kept showing as working. Two causes:

  1. Turn check read "a delegated task" as working even when this agent won't follow it.
  2. Sidebar and tab drew a turn-check "working" verdict (agent_state: finished + unfinished) with the same spinner as a live turn. Only the dashboard card said "Waiting on its work".
 working: something runs now that the agent waits on or acts on when it reports back:
-  the agent itself, a delegated task, a build, …
+  the agent itself, a delegated task it waits on or will act on when it reports back, a build, …
+  A task handed to a separate worker or session that this agent won't follow is a handoff:
+  "you can close this session", "followed elsewhere", "turned off its notifications to this
+  session" → done, even while the worker still runs.
 sessionState(s)                     // unchanged: still "running" for counts, the board, grouping
+onItsWork(s)  = finished && unfinished
+allOnItsWork  = every "running" session in a row is onItsWork
 <TaskGlyph state onWork>            // sidebar rows, folded headers and forks, Labs columns
 <StateGlyph state session noTip>    // tabs, a pane's header, the rail's card
-  running → spinner
+  running && onItsWork → hourglass (WorkGlyph), with "Its turn ended while its work runs on…"
+                         in its tip, or in the tab's / pane's own tooltip (noTip)

A new turn clears unfinished on the box (turns.go), so the spinner comes back while it runs.

Evidence

Turn check on the real model (TestTurnCheckLive, new, skipped unless BERTH_TURNCHECK_LIVE_KEY_FILE and BERTH_TURNCHECK_LIVE_MODEL are set; CI never calls a provider). Same fixtures, typesafe-ai/jev on AI Gateway:

TurnBefore (main's prompt), 3 runsAfter, 5 runs
handoff to a worker it won't followworking 0.76 / 0.77 / 0.80 ❌done 0.78–0.85 ✅
delegated task it waits onworking 1.00working 0.99–1.00
deploy runningworking 1.00working 1.00
background re-checkworking 0.92–0.95working 0.95–0.97
check-in scheduled for tomorrowparked 0.88–0.92parked 0.85–0.90

On a real box, the real turn (.claude/skills/verify-berthd/scripts/turn-check-replay.sh, new: a throwaway location, a stand-in claude session, the turn as its transcript, the UserPromptSubmit and Stop hooks through berthd hook). Before, on the installed v0.3.30 build, 2 of 2 runs:

replay-cp9-handoff        agent_state=finished unfinished=True parked=False   ← shows as working
replay-delegated-waiting  agent_state=finished unfinished=True parked=False

The after run on a build with this branch is in a comment below.

App (drive.mjs turn-check-indicator, mock fixtures, no console errors; e2e/sidebar-turn-check.spec.ts asserts the same):

Sidebar: hourglass and its tip (order-export), spinner for a live turn (qa-deck)Tab: hourglass, tooltip says whyLive turn's tab keeps the spinner
sidebartablive

Before, the sidebar's order-export row and its tab drew the blue spinner.

Checks (local; fork PRs need a maintainer for CI): go vet ./... ✅; go test -race ./... ✅ except internal/leadline/api TestDetach, which failed once under the full suite's load and passes 5/5 alone (a package this PR doesn't touch). pnpm typecheck:plugins, check:titles, check:type, check:grid, check:csp, check:themes, test (incl. new derive.test.ts cases), build, tsc -p e2e ✅. e2e/sidebar-turn-check.spec.ts + e2e/dashboard-turn-check.spec.ts ✅. Docs build ✅. Codex review and adversarial review: no findings.

Merge Danger

Door: two-way

Prompt text and a UI indicator; no data or schema. Revert to undo.

Blast Radius: classification

Every box's turn check uses the new wording, so a borderline delegated turn could now read as done (it then shows as done instead of working; nothing is lost and no prompt is held). The live replay keeps a delegated-and-waiting turn at working 0.99–1.00. In the app, sessionState() is unchanged, so counts, the dashboard's columns and notifications are untouched; only the glyph changes.

🤖 Generated with Claude Code