Show blue (working), not green (done), while background subagents run - #5
Merged
camwilso merged 5 commits intoAug 14, 2026
Merged
Conversation
While a session has background subagents in flight, a Stop that would otherwise paint the session .done should paint .working instead — the turn ending is not the same as the work being finished. Track a per-session delegatedCount, incremented on SubagentStart and decremented (floor 0) on SubagentStop via a name-keyed carve-out ahead of Eligibility.evaluate. Every Stop authoritatively reconciles the count from background_tasks (filtered to type=="subagent") and overrides the .done write to .working while the count is > 0. No new SessionState case, no persistence, no UI diff.
Hardware validation found a hole: Claude Code's idle_prompt Notification (fired ~60s after a turn ends) is mapped by user config and repaints a delegating .working key to slate, since mayReplace only guards done->idle, not working->idle. Add EventMapper.suppressesDelegating(eventName:matcher:delegatedCount:), keyed on the idle_prompt subtype (not the mapped state, since users can remap it to anything) and gated on delegatedCount > 0. Wire it into BoardController.handle's Stop-override branch ahead of the existing reconcile, skipping setState entirely when it fires. permission_prompt/agent_needs_input/elicitation_dialog are untouched — orange still wins. Add positive log lines for both the suppression and the existing Stop-deferred-to-working override, matching the file's "hook <name> maps to <state> [<sid8>]" log grammar.
A bare Int counter could not distinguish a late SubagentStop for an
agent a prior Stop reconcile had already dropped from one it still
carried, letting the count decrement past truth and drop delegating
early (found in hardware validation: idle_prompt not suppressed while
an agent still ran). SessionRegistry.Entry.delegatingAgentIDs:
Set<String> replaces delegatedCount; adjustDelegation inserts/removes
by agent_id (removing an absent id is a documented no-op — this is the
actual fix); reconcileDelegation replaces the set wholesale from Stop's
background_tasks. Missing/empty agent_id degrades to a no-op
insert/remove rather than a placeholder id, relying on the next Stop's
reconcile. suppressesDelegating keeps its Int parameter; call sites now
pass delegatingAgentIDs.count. RegistryStore persistence untouched
(live-only, same as before).
Adds the exact regression: insert A, insert B, reconcile to {B} (A
already dropped from background_tasks), late SubagentStop for A
arrives — set stays {B}, still delegating.
…ource-scan test - Soften the "only Stop ever [maps to .done]" comment in BoardController.handle: Pi's turn_end/agent_settled and a remapped Notification subtype also reach the same override. - Fix stale delegatedCount reference in HookTests.swift's backgroundSubagentIDs comment (replaced by delegatingAgentIDs). - Add a source-scan regression test (HookTests.swift) pinning that BoardController actually wires adjustDelegation/reconcileDelegation/ suppressesDelegating and the delegatingAgentIDs override — fails if the wiring hunk is reverted. - Document the synthetic-wake gotcha (the CLI's observed follow-on Stop after the last background agent lands) directly in BoardController.swift.
Owner
|
Shipped in v0.2.0. Verified on hardware before cutting the release: a session dispatching a subagent held working-blue through the full run and went green when the agent landed, with all three log paths (Stop deferral, idle_prompt suppression, resume) behaving as your PR described. Thanks — this made the board noticeably more honest. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A session that dispatches background subagents (the
Agenttool withrun_in_background) firesStopthe moment the main turn's prompt goes idle —Stop's ownlast_assistant_messageis typically just "Dispatched." at that point,while Claude Code's own UI reports "Waiting for N background agents to finish."
OpenBoard's
EventMapper.statemapsStopunconditionally to.done, and the boardpaints the done-green. The key reads "finished" while one or more agents are still
working — with any agent-dispatch workflow this is a constant, structural misread, not
an edge case.
No new state is needed to fix it:
SessionState.working's default appearance isalready a strong, saturated blue, distinct from
idle's desaturated slate blue, and adelegating session genuinely is still working on the user's behalf — the main turn
just isn't the part doing it anymore.
Approach
Two new hooks are installed —
SubagentStart/SubagentStop, both emitted by theparent session about its own subagent bookkeeping, no matcher needed since every
firing is relevant.
Stopwas already installed and needed no wiring change, only anew payload field read and an override of its existing
.donewrite, gated on whetherthe in-flight subagent set is non-empty.
Both new events carry
agent_id/agent_type, which would otherwise tripEligibility.evaluate's very first two checks and be refused as subagent-sourcedtraffic — that fail-close protects the six-key budget from subagent fan-out and is not
loosened. Instead, a carve-out branch sits ahead of
Eligibility.evaluateinBoardController.handle, keyed on the two literal event names only (never on "any eventwith an agent field"), so a subagent's own
PreToolUse/PostToolUse— which carries thesame fields — still falls through to
Eligibility.evaluateunchanged and is refusedexactly as today. One honest trade worth flagging: the carve-out is a name match, so it
also bypasses the
CLAUDE_AGENT_ID/CLAUDE_AGENT_TYPEenvironment checksEligibility.evaluatewould otherwise apply — in principle a nested subagent (onethat itself dispatches an
Agenttool call) could fire its ownSubagentStartand reachadjustDelegation, over-counting. This is bounded by the authoritative reconcile below,which replaces the count with the true
background_taskssize on everyStopregardless of how the count got there — and it can never claim a key:
adjustDelegationonly adjusts a counter on a session that already holds one, the same "never allocates"
rule
setStatealready follows.Each session entry keeps a set of in-flight subagent
agent_ids(
delegatingAgentIDs), not a bare counter. A bare counter (floored at 0) was the firstcut, and hardware testing found a real race: a late-arriving
SubagentStopfor an agentthat a
Stopreconcile had already dropped from the count would decrement it again foran agent no longer being tracked, driving the count below the true number still in
flight — an undercount, which is exactly the bug this PR exists to fix, recurring
through the fix itself (observed on hardware as
idle_promptnot being suppressed whilean agent was still running). A set doesn't have that failure mode — removing an id that
isn't present is a documented no-op. Every
Stopreconciles the set wholesale from thatStop's ownbackground_tasksarray (filtered totype == "subagent"), never trustingincremental drift across
Stops — this is the authoritative, self-healing step, provencorrect for out-of-order and non-head array removal. While the set is non-empty, a
Stopthat would paint.donepaints.workinginstead.idle_promptis left unmappedby default and so is harmless out of the box; but a user who has remapped it (commonly
to
.idle) gets the same override while delegating — the remap is suppressed by subtype,not by the state it happens to be pointed at, since the false signal is the timer firing
at all.
permission_prompt/agent_needs_input/elicitation_dialogare untouched andstill win their orange precedence over a delegating
.working.When the last agent lands, nothing is deferred or replayed: on every capture taken for
this work, the CLI followed a background agent's completion with a synthetic wake
(
UserPromptSubmit+ a realStop). ThatStop'sbackground_tasksis empty, thereconcile drops the set to zero, and
.doneis applied through the exact same code patha normal
Stopalready uses. This is observed CLI behavior, not a documented contract —if it ever stopped happening, the affected key would simply stay blue until the next
real user turn's own
Stop, rather than mis-painting anything.Deliberately not touched:
EventMapper.state's existing mappings — no new switch case anywhere.Stopkeeps mapping to
.done; the override happens one layer up, inBoardController.handle, not inside the mapper, gated on the in-flight set beingnon-empty rather than on a counter.
mayReplace— no new state means no new precedence question to guard.RegistryStorepersistence — the set is deliberately not persisted, following theexisting precedent that
pendingTool(equally per-turn bookkeeping) isn't persistedeither. On relaunch a restored entry starts empty; the next
Stopcorrects it.Eligibility.evaluate's core fail-close predicate — the carve-out is a new branchahead of it for two named events, not an edit to its logic. Subagents still never
claim keys; every other subagent-sourced event, including the subagent's own tool
calls, is refused exactly as before.
ColorsPane,HarnessPane's subtype-remap picker,Ambient.priority/Ambient.normalise/Laps.show, the popover badge tint. Zero UI diff: no new state,no new color, no new settings surface. A delegating session paints, laps, and rings
exactly as
.workingalready does, because it is.working.shell,monitor,workflow,teammate,cloud session,MCP task) — theStop-time reconcile filtersbackground_taskstotype == "subagent"only, so a session paused on one of these still reads.done.Not a regression; an explicit scope decision matching "background agents," not "any
background work." Widening the filter is future work.
background_tasksentry lacking anid— ignored by the filter, so it cannever be tracked or reconciled, and never contributes to the delegating count either
way.
killed mid-delegation before another
Stopfires, nothing clears the stuck.workingexcept a later relaunch or
SessionEnd. Documented as a known, bounded limitation,not engineered around with a watchdog — consistent with this codebase's existing
tolerance for documented-rather-than-defended-against gaps.
Keeping the diff to this shape (a carve-out branch plus an override gated on the
in-flight set being non-empty, instead of a ninth
SessionStatecase) means nocompiler-enforced exhaustive switch needs a new case, no new hardware color needs to be
chosen, and the change is reviewable as "does this override the right write, at the
right time" rather than "does every
SessionState-aware site correctly handle a newvalue."
What changed
mac/Sources/OpenBoard/BoardController.swift— the carve-out branch ahead ofEligibility.evaluateforSubagentStart/SubagentStop; theidle_promptfalse-demotion guard; the override of the
Stop→.donewrite, gated on thein-flight set being non-empty.
mac/Sources/OpenBoardKit/HookInstall.swift—SubagentStart/SubagentStopadded toevents, no matcher (every firing is relevant, unlikeNotification's subtypefilter).
mac/Sources/OpenBoardKit/HookServer.swift—backgroundSubagentIDs, a typedaccessor over
background_tasksfiltered totype == "subagent", so the filter isunit-testable from a constructed
Eventrather than exposed as raw dictionary pokingat each call site.
mac/Sources/OpenBoardKit/SessionRegistry.swift—delegatingAgentIDs: Set<String>on
Entry;adjustDelegation(insert/remove by id, never allocates a slot);reconcileDelegation(authoritative wholesale replace onStop);EventMapper.suppressesDelegating(theidle_prompt-while-delegating guard).mac/Sources/OpenBoardKit/SessionState.swift— one-line reword of.working'smeanstext ("A turn — or a delegated subagent — is running.") so the popover stayshonest about what's actually running while delegating.
mac/Sources/OpenBoardTests/AppPathsTests.swift,mac/Sources/OpenBoardTests/HookInstallTests.swift— the two "every wired/describedevent maps to something" cross-checks add
SubagentStart/SubagentStopto the sameexclusion
Notificationalready has, since both are handled by the carve-out ahead ofEventMapper, not by the mapper itself.mac/Sources/OpenBoardTests/HookTests.swift— new coverage forbackgroundSubagentIDs's subagent-only filter (including the absent/empty cases), anda wiring regression test that source-scans
BoardController.swiftfor the four callsites (
adjustDelegation,reconcileDelegation,suppressesDelegating,delegatingAgentIDs) this design depends on.mac/Sources/OpenBoardTests/RegistryTests.swift— new cases foradjustDelegation/reconcileDelegation(insert/remove/no-op-on-unknown-session,wholesale reconcile, the late-
SubagentStop-after-reconcile race the set fixes) andfor the
Stop-branch override choosing.workingvs.donecorrectly.Testing
Ran
swift run OpenBoardTestsfrommac/on both ends of this branch. Baseline onupstream
main(1923298, the iTerm2-support merge): 432/432. On this branch:444/444 — 12 new tests, no regressions. The suite follows this repo's own convention
of a plain executable rather than XCTest (
swift run OpenBoardTests); the newwiring-regression test in
HookTests.swiftsource-scansBoardController.swiftfor thecall sites this design depends on rather than executing them, since the test target
can't import the
OpenBoardapp executable target those calls live in — the same tradeFocusITerm2Tests.swiftalready makes forFocus.swift/Actions.swift.Hardware validation
Run against a real Codex Micro (wired USB) to close the headless-only gap in the
original hook-signal spike this design is built on. Suite at 443/443 on the build
carrying both fixes below; the one commit landed since (
0b6b3a7) is comment polishplus a source-scan wiring-regression test — no behavior change — which is what brings
the suite to the 444/444 reported above. The matrix below is reported per build on
purpose: two of the rows are the reason this branch carries two fix commits rather than
one, and each fix was found by a hardware failure and only re-validated, not assumed,
on the build that addressed it.
working's blue for the wholedelegation window, painted
.doneonly on the follow-onStopafter the agentlanded. Held on the initial feature build and confirmed again on the first fix
build.
Stop): heldblue through the dispatch
Stop, painted.doneon the completionStop. Confirmedacross all builds.
fix commits exist rather than one. First run exposed the
idle_promptfalse-demotion, fixed by
1d3cf74. Re-running that case on the fixed build thenexposed a second, distinct bug — a late-arriving
SubagentStopundercounting a barecounter while a replacement agent was still in flight — fixed by
5e95a09(
delegatedCount→Set<agent_id>). On the build carrying both fixes: 6-secondrecovery from kill to
.done, no stuck-blue or false-white window.idle_promptboundary: failed pre-fix — the keyrepainted to slate at exactly +60s, the
idle_promptNotification firing on scheduleand clobbering the delegating
.working. Passed post-fix, with the positivesuppression log line (
idle_prompt suppressed (delegating, N in flight)) firing atthe same +60s mark instead of a repaint.
to the delegating blue after the turn resolves, no separate code path needed.
orange still wins over the delegating blue, and the key resumes blue once the prompt
is answered.
stayed blue until the last agent reconciled to zero regardless of dispatch/finish
order. Not re-run on the two fix builds — neither fix touches the multi-agent
reconcile path — and hook-level out-of-order is separately covered by the captured
spike scenario.
observed continuously across all builds.
The matrix closed with a real agent-dispatch workflow run end-to-end — the exact
scenario that motivated this change — with the key holding working-blue through
the whole delegation window and painting done only when the agents drained.
Happy to narrow scope or split the
idle_promptguard into its own PR if you'd ratherreview the core carve-out/reconcile mechanism separately from that fix.