Skip to content

Show blue (working), not green (done), while background subagents run - #5

Merged
camwilso merged 5 commits into
camwilso:mainfrom
ebowman:feature/delegating-state-upstream
Aug 14, 2026
Merged

Show blue (working), not green (done), while background subagents run#5
camwilso merged 5 commits into
camwilso:mainfrom
ebowman:feature/delegating-state-upstream

Conversation

@ebowman

@ebowman ebowman commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Problem

A session that dispatches background subagents (the Agent tool with
run_in_background) fires Stop the moment the main turn's prompt goes idle —
Stop's own last_assistant_message is typically just "Dispatched." at that point,
while Claude Code's own UI reports "Waiting for N background agents to finish."
OpenBoard's EventMapper.state maps Stop unconditionally to .done, and the board
paints 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 is
already a strong, saturated blue, distinct from idle's desaturated slate blue, and a
delegating 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 the
parent session about its own subagent bookkeeping, no matcher needed since every
firing is relevant. Stop was already installed and needed no wiring change, only a
new payload field read and an override of its existing .done write, gated on whether
the in-flight subagent set is non-empty.

Both new events carry agent_id/agent_type, which would otherwise trip
Eligibility.evaluate's very first two checks and be refused as subagent-sourced
traffic — 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.evaluate in
BoardController.handle, keyed on the two literal event names only (never on "any event
with an agent field"), so a subagent's own PreToolUse/PostToolUse — which carries the
same fields — still falls through to Eligibility.evaluate unchanged and is refused
exactly as today. One honest trade worth flagging: the carve-out is a name match, so it
also bypasses the CLAUDE_AGENT_ID/CLAUDE_AGENT_TYPE environment checks
Eligibility.evaluate would otherwise apply — in principle a nested subagent (one
that itself dispatches an Agent tool call) could fire its own SubagentStart and reach
adjustDelegation, over-counting. This is bounded by the authoritative reconcile below,
which replaces the count with the true background_tasks size on every Stop
regardless of how the count got there — and it can never claim a key: adjustDelegation
only adjusts a counter on a session that already holds one, the same "never allocates"
rule setState already 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 first
cut, and hardware testing found a real race: a late-arriving SubagentStop for an agent
that a Stop reconcile had already dropped from the count would decrement it again for
an 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_prompt not being suppressed while
an 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 Stop reconciles the set wholesale from that
Stop's own background_tasks array (filtered to type == "subagent"), never trusting
incremental drift across Stops — this is the authoritative, self-healing step, proven
correct for out-of-order and non-head array removal. While the set is non-empty, a
Stop that would paint .done paints .working instead. idle_prompt is left unmapped
by 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_dialog are untouched and
still 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 real Stop). That Stop's background_tasks is empty, the
reconcile drops the set to zero, and .done is applied through the exact same code path
a normal Stop already 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. Stop
    keeps mapping to .done; the override happens one layer up, in
    BoardController.handle, not inside the mapper, gated on the in-flight set being
    non-empty rather than on a counter.
  • mayReplace — no new state means no new precedence question to guard.
  • RegistryStore persistence — the set is deliberately not persisted, following the
    existing precedent that pendingTool (equally per-turn bookkeeping) isn't persisted
    either. On relaunch a restored entry starts empty; the next Stop corrects it.
  • Eligibility.evaluate's core fail-close predicate — the carve-out is a new branch
    ahead 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.
  • All UIColorsPane, 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 .working already does, because it is .working.
  • Non-subagent background tasks (shell, monitor, workflow, teammate, cloud session, MCP task) — the Stop-time reconcile filters background_tasks to
    type == "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.
  • A background_tasks entry lacking an id — ignored by the filter, so it can
    never be tracked or reconciled, and never contributes to the delegating count either
    way.
  • Hard process kill — same as any hook-driven state: if the whole CLI process is
    killed mid-delegation before another Stop fires, nothing clears the stuck .working
    except 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 SessionState case) means no
compiler-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 new
value."

What changed

  • mac/Sources/OpenBoard/BoardController.swift — the carve-out branch ahead of
    Eligibility.evaluate for SubagentStart/SubagentStop; the idle_prompt
    false-demotion guard; the override of the Stop.done write, gated on the
    in-flight set being non-empty.
  • mac/Sources/OpenBoardKit/HookInstall.swiftSubagentStart/SubagentStop added to
    events, no matcher (every firing is relevant, unlike Notification's subtype
    filter).
  • mac/Sources/OpenBoardKit/HookServer.swiftbackgroundSubagentIDs, a typed
    accessor over background_tasks filtered to type == "subagent", so the filter is
    unit-testable from a constructed Event rather than exposed as raw dictionary poking
    at each call site.
  • mac/Sources/OpenBoardKit/SessionRegistry.swiftdelegatingAgentIDs: Set<String>
    on Entry; adjustDelegation (insert/remove by id, never allocates a slot);
    reconcileDelegation (authoritative wholesale replace on Stop);
    EventMapper.suppressesDelegating (the idle_prompt-while-delegating guard).
  • mac/Sources/OpenBoardKit/SessionState.swift — one-line reword of .working's
    means text ("A turn — or a delegated subagent — is running.") so the popover stays
    honest about what's actually running while delegating.
  • mac/Sources/OpenBoardTests/AppPathsTests.swift,
    mac/Sources/OpenBoardTests/HookInstallTests.swift — the two "every wired/described
    event maps to something" cross-checks add SubagentStart/SubagentStop to the same
    exclusion Notification already has, since both are handled by the carve-out ahead of
    EventMapper, not by the mapper itself.
  • mac/Sources/OpenBoardTests/HookTests.swift — new coverage for
    backgroundSubagentIDs's subagent-only filter (including the absent/empty cases), and
    a wiring regression test that source-scans BoardController.swift for the four call
    sites (adjustDelegation, reconcileDelegation, suppressesDelegating,
    delegatingAgentIDs) this design depends on.
  • mac/Sources/OpenBoardTests/RegistryTests.swift — new cases for
    adjustDelegation/reconcileDelegation (insert/remove/no-op-on-unknown-session,
    wholesale reconcile, the late-SubagentStop-after-reconcile race the set fixes) and
    for the Stop-branch override choosing .working vs .done correctly.

Testing

Ran swift run OpenBoardTests from mac/ on both ends of this branch. Baseline on
upstream 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 new
wiring-regression test in HookTests.swift source-scans BoardController.swift for the
call sites this design depends on rather than executing them, since the test target
can't import the OpenBoard app executable target those calls live in — the same trade
FocusITerm2Tests.swift already makes for Focus.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 polish
plus 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.

  • Single background-agent dispatch: key held working's blue for the whole
    delegation window, painted .done only on the follow-on Stop after the agent
    landed. Held on the initial feature build and confirmed again on the first fix
    build.
  • Green only when the agent actually lands (not on the dispatch-only Stop): held
    blue through the dispatch Stop, painted .done on the completion Stop. Confirmed
    across all builds.
  • An agent killed mid-flight: found failing twice — this is exactly why the two
    fix commits exist rather than one. First run exposed the idle_prompt
    false-demotion, fixed by 1d3cf74. Re-running that case on the fixed build then
    exposed a second, distinct bug — a late-arriving SubagentStop undercounting a bare
    counter while a replacement agent was still in flight — fixed by 5e95a09
    (delegatedCountSet<agent_id>). On the build carrying both fixes: 6-second
    recovery from kill to .done, no stuck-blue or false-white window.
  • Quiet delegation past the 60s idle_prompt boundary: failed pre-fix — the key
    repainted to slate at exactly +60s, the idle_prompt Notification firing on schedule
    and clobbering the delegating .working. Passed post-fix, with the positive
    suppression log line (idle_prompt suppressed (delegating, N in flight)) firing at
    the same +60s mark instead of a repaint.
  • Second user turn submitted while delegating: passed on the final build — returns
    to the delegating blue after the turn resolves, no separate code path needed.
  • Permission-prompt precedence while delegating: passed on the final build —
    orange still wins over the delegating blue, and the key resumes blue once the prompt
    is answered.
  • Parallel agents (2+), out-of-order finish: passed on the initial feature build,
    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.
  • Plain-turn regression, no agents involved: idle → working → done unchanged,
    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_prompt guard into its own PR if you'd rather
review the core carve-out/reconcile mechanism separately from that fix.

ebowman and others added 5 commits August 13, 2026 21:16
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.
@camwilso
camwilso merged commit 35f9924 into camwilso:main Aug 14, 2026
1 check passed
@camwilso

Copy link
Copy Markdown
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.

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.

2 participants