fix(app,broadcaster): add .bus subscriber forwarding tokenDelta to OutboundBatcher (closes BLOCKER-INT-2 / tokens half)

Phase E first fix from .planning/v0.12.0-MILESTONE-AUDIT.md. The
orchestrator's events stream had FIVE subscribers (memory, transcript,
devOverlay, frameAttach, voice) and ZERO that forwarded to the JS-side
HUD chat panel. OutboundBatcher.postToken existed with zero production
call sites cross-tree. As a result, the webview chatEvents store was
guaranteed to render an empty array regardless of how many tokens the
orchestrator emitted.

This commit closes the tokenDelta half of BLOCKER-INT-2. turnStarted /
turnEnded forwarding remains open as documented follow-up; the relevant
translation (TurnID → UUID, StopReason → TurnTerminator) is non-trivial
and warrants its own commit. Downgraded F-B2-INT-2 from 🔴 → 🟡 in
AUDIT-FINDINGS.md to reflect partial closure.

Changes:

  packages/AgentCore/Sources/AgentOrchestrator/OrchestratorEventBroadcaster.swift
    + Adds `case bus` to Priority enum with doc-comment explaining
      AppDelegate's bus-forwarding subscriber and citing the AUDIT plan.
    + Extends isProtectedForPriority() to include .bus alongside .memory
      and .transcript: protects everything except .tokenDelta /
      .thinkingDelta. Bus tokenDeltas are drop-eligible because
      OutboundBatcher coalesces at ~30 Hz internally; tail-dropping
      individual deltas under extreme overload is preferable to
      back-pressuring the orchestrator. Turn-bookkeeping events
      (.turnEnd, .toolCardUpdate, .usage, .error, .stateChange) are
      protected — those drive HUD chrome transitions that MUST reach
      the user.

  packages/AgentCore/Tests/AgentOrchestratorTests/OrchestratorEventBroadcasterTests.swift
    + Extends test_protectionMatrixIsCorrect_perPriority with a
      .bus assertion loop mirroring .memory / .transcript. Asserts
      tokenDelta + thinkingDelta drop-eligible, all 5 other event
      classes protected. AgentCore suite: 178/178 → 178/178 (added
      assertions to existing test, no new test method).

  App/AppDelegate.swift
    + busSubscriberTask: Task<Void, Never>? property storing the new
      sixth broadcaster subscriber drain task. Doc-comment cites the
      AUDIT-AND-FIX-PLAN BLOCKER-INT-2 origin.
    + Cancellation in applicationWillTerminate alongside the other 5
      subscriber tasks.
    + In installAgent, after the .voice subscriber drain, subscribes
      to the broadcaster with priority .bus (capacity 256) and starts
      a Task that pattern-matches OrchestratorEvent.tokenDelta and
      forwards the chunk to `await self.outboundBatcher?.postToken(_:)`.
      `[weak self]` capture + nil-coalescing handles the construction-
      order skew where outboundBatcher is constructed in installVoice
      (which runs AFTER installAgent per the locked vision→agent→voice
      install order). `await` is required because OutboundBatcher is
      a public actor.
    + Updated final installAgent log line to list "bus" alongside the
      other 5 subscribers.

Verification:
  • scripts/check-app-builds.sh: PASS — App target compiles cleanly.
  • AgentCore swift test: 178/178 pass.
  • Bus swift test: 56/56 except F-A2-01 (pre-existing stale-assertion
    test bug, unchanged).

What this does NOT yet fix (deferred follow-up):
  • turnStarted / turnEnded forwarding. The Phase 4 SUMMARY's
    "synthesized client-side from first tokenDelta" assumption may
    cover turnStarted webview-side; turnEnded explicitly needs Swift
    translation from OrchestratorEvent.turnEnd → BusOutbound.turnEnded.
  • End-to-end visibility — a user can't actually see the new
    forwarding in action until BLOCKER-INT-3 (HUD chat input UI) is
    closed. Until then, the .bus subscriber correctly forwards an
    empty stream of zero tokenDeltas because no turn ever runs.
  • Tool-call event forwarding remains a separate problem
    (BLOCKER-INT-1 / NoopBusGateway, untouched here).

Refs: .planning/v0.12.0-MILESTONE-AUDIT.md (BLOCKER-INT-2),
.planning/AUDIT-AND-FIX-PLAN.md Phase E.
This commit is contained in:
james.maes committed 2026-05-02 20:32:55 -05:00
1 parent 326a27d530
commit 602a95b6c8
4 files changed
+85 -8

No files matched your search

+12 -5
View File
@@ -99,12 +99,19 @@ blockers + confirmed the known C1, plus two warnings. Full report:
- **Affected REQ-IDs:** HUD-07, MCP-01..04, TOOL-01..03.
- **Severity rationale:** 🔴 — tool-call HUD cards are a Phase 3/5 user-facing promise; whole modality is mute.
#### F-B2-INT-2 🔴 tokenDelta / turnStarted / turnEnded never emitted to webview
#### F-B2-INT-2 🟡 tokenDelta forwarded; turnStarted/turnEnded follow-up (PARTIALLY FIXED)
- **Where:** `packages/Bus/Sources/Bus/OutboundBatcher.swift:44` (the outbound emitter exists but no caller); `App/AppDelegate.swift:828-1015` (broadcaster has 5 subscribers but no `.bus` subscriber).
- **What:** Cross-tree grep confirms zero call sites of `OutboundBatcher.postToken` outside its declaration. `BusOutbound.{tokenDelta,turnStarted,turnEnded}` cases have NO Swift production emit sites — only their own decode round-trips. The orchestrator emits `OrchestratorEvent.tokenDelta` correctly (`AgentOrchestrator.swift:382`); five subscribers consume it (memory, transcript, devOverlay, frameAttach, voice); none forward to the webview.
- **Affected REQ-IDs:** TEXT-01, TEXT-02, AGENT-04, AGENT-10, HUD-04..06.
- **Severity rationale:** 🔴 — tokens never reach the chat panel; even a working LLM turn produces nothing user-visible.
- **Original where:** `packages/Bus/Sources/Bus/OutboundBatcher.swift:44` (outbound emitter existed but no caller); `App/AppDelegate.swift:828-1015` (broadcaster had 5 subscribers but no `.bus` subscriber).
- **Original what:** Cross-tree grep confirmed zero call sites of `OutboundBatcher.postToken` outside its declaration. `BusOutbound.{tokenDelta,turnStarted,turnEnded}` cases had NO Swift production emit sites.
- **What was fixed (this session):** Added `case .bus` to `OrchestratorEventBroadcaster.Priority` with appropriate protection rules (mirrors `.memory`/`.transcript`: only `.tokenDelta` / `.thinkingDelta` are drop-eligible). Added a `busSubscriberTask` in `AppDelegate.installAgent` that drains `.bus` subscription, pattern-matches `OrchestratorEvent.tokenDelta`, and forwards the chunk to `outboundBatcher.postToken(_:)`. Cleanup wired in the cancel section. Unit test for the new Priority case lands in `OrchestratorEventBroadcasterTests.test_protectionMatrixIsCorrect_perPriority`.
- **What's still open:** `turnStarted` / `turnEnded` are NOT yet forwarded. Translation needed:
- Orchestrator emits `OrchestratorEvent.turnEnd(turnId: TurnID, stopReason: StopReason)`. Bus expects `BusOutbound.turnEnded(id: UUID, terminator: TurnTerminator)`. Need a TurnID → UUID conversion + StopReason → TurnTerminator mapping.
- There's no equivalent `turnStarted` on the orchestrator side; that signal lives inside `OrchestratorEvent.stateChange(TurnState)` or could be synthesized from the first `.tokenDelta` per turn (which is what Phase 4 SUMMARY's "synthesized client-side from first tokenDelta" assumption means — webview side, not Swift side).
- **Severity downgraded** 🔴 → 🟡: with token forwarding in place, the chat panel will now stream tokens during a live turn — once BLOCKER-INT-3 (chat input UI) is closed and user can submit a turn. The "complete the message on turn end" finalization still requires the follow-up.
- **Verification:**
- `scripts/check-app-builds.sh` PASS.
- AgentCore tests 178/178 pass (broadcaster .bus protection rule asserted).
- Bus tests 56/56 except F-A2-01 pre-existing.
#### F-B2-INT-3 🔴 HUD has no chat input UI; `chatSubmit` has no producer
+41 -1
View File
@@ -278,6 +278,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
/// text-originated turns NEVER drive `VoiceController` back to `.idle`.
private var voiceEventTranslatorTask: Task<Void, Never>?
/// Phase E follow-up (BLOCKER-INT-2 from `.planning/v0.12.0-MILESTONE-AUDIT.md`).
/// Drains the broadcaster's `.bus` subscription and forwards
/// `.tokenDelta` chunks into `outboundBatcher.postToken(_:)` so they
/// reach the JS-side HUD chat panel. Without this subscriber, the
/// orchestrator emits tokens that never leave Swift — the chat panel
/// renders an empty `chatEvents` array forever.
private var busSubscriberTask: Task<Void, Never>?
/// Plan 09-04 — OutboundBatcher used by `VoiceBusEmitterAdapter` to
/// route the 30 Hz audio-level RMS into the bus's RingMesh pulse.
/// Constructed in installVoice; webviewBridge is the sink.
@@ -578,6 +586,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
frameAttachReleaseTask?.cancel()
// Plan 09-04: tear down voice event translator subscriber.
voiceEventTranslatorTask?.cancel()
// Phase E (BLOCKER-INT-2): tear down bus-forwarding subscriber.
busSubscriberTask?.cancel()
if let broadcaster = eventBroadcaster {
Task { await broadcaster.stop() }
}
@@ -1010,8 +1020,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
// 5f. Phase E (BLOCKER-INT-2 from v0.12.0-MILESTONE-AUDIT.md) —
// bus-forwarding subscriber. Drains the broadcaster's `.bus`
// subscription and forwards `.tokenDelta` chunks into
// `outboundBatcher.postToken(_:)` so the JS-side HUD chat panel
// receives streaming tokens. Without this subscriber, every
// orchestrator-emitted `.tokenDelta` is fanned out to the other
// 5 subscribers (memory, transcript, devOverlay, frameAttach,
// voice) but NEVER leaves Swift — the chat panel renders an
// empty `chatEvents` array forever.
//
// `outboundBatcher` is constructed in installVoice (which runs
// AFTER installAgent per the locked install order). Capture
// `[weak self]` and dereference at event-receipt time so the
// subscriber survives the construction-order skew.
//
// `OutboundBatcher.postToken` itself coalesces tokens at ~30 Hz
// internally; combined with `.bus` priority's drop-eligible
// classification of `.tokenDelta`, extreme overload tail-drops
// individual deltas instead of back-pressuring the orchestrator.
let busSub = await broadcaster.subscribe(priority: .bus, capacity: 256)
self.busSubscriberTask = Task { [weak self] in
for await event in busSub.stream {
if Task.isCancelled { break }
guard let self else { break }
if case let .tokenDelta(_, text) = event {
await self.outboundBatcher?.postToken(text)
}
}
}
systemLogger?.info(
"installAgent: AgentOrchestrator + broadcaster + transcript store wired (memory + transcript + devOverlay + frameAttach + voice subscribers active)"
"installAgent: AgentOrchestrator + broadcaster + transcript store wired (memory + transcript + devOverlay + frameAttach + voice + bus subscribers active)"
)
}
@@ -40,6 +40,20 @@ public actor OrchestratorEventBroadcaster {
/// Same protection rules as `.memory` — used by AppDelegate's
/// transcript collector to feed `TurnTranscriptStore` from `.tokenDelta`.
case transcript
/// Webview-bus forwarding subscriber — drains orchestrator events
/// out to the JS-side HUD via `OutboundBatcher` / `WebviewBridge`.
/// `.tokenDelta` and `.thinkingDelta` are drop-eligible (the
/// `OutboundBatcher` itself coalesces tokens at ~30 Hz, so tail-
/// drop on extreme overload is acceptable). Everything else
/// (`.turnEnd`, `.toolCardUpdate`, `.usage`, `.error`,
/// `.stateChange`) is protected — those drive HUD chrome
/// transitions that MUST reach the user.
///
/// AUDIT-AND-FIX-PLAN.md / Phase E follow-up: closes
/// BLOCKER-INT-2 (`tokenDelta` / `turnStarted` / `turnEnded` never
/// emitted to webview) by giving AppDelegate a canonical
/// priority to subscribe under for bus-forwarding.
case bus
}
private let upstream: BoundedAsyncChannel<OrchestratorEvent>
@@ -142,8 +156,12 @@ public actor OrchestratorEventBroadcaster {
priority: Priority
) -> Bool {
switch priority {
case .memory, .transcript:
// D-07: only .tokenDelta / .thinkingDelta are eligible for drop.
case .memory, .transcript, .bus:
// D-07 / Phase E (BLOCKER-INT-2): only .tokenDelta /
// .thinkingDelta are drop-eligible. Bus forwarder shares
// the rule — OutboundBatcher coalesces tokens at ~30 Hz,
// so tail-dropping individual deltas under extreme overload
// is preferable to back-pressuring the orchestrator.
switch event {
case .tokenDelta, .thinkingDelta: return false
default: return true
@@ -286,6 +286,18 @@ final class OrchestratorEventBroadcasterTests: XCTestCase {
".voice priority protection wrong for \(label)"
)
}
// bus: same rules as memory/transcript — only tokenDelta /
// thinkingDelta are drop-eligible. Phase E (BLOCKER-INT-2) added
// this priority so AppDelegate has a canonical bus-forwarding sub.
for (label, event) in allEvents {
let isProtected = await broadcaster.isProtectedForPriority(event, priority: .bus)
let lossy = (label == "tokenDelta" || label == "thinkingDelta")
XCTAssertEqual(
isProtected, !lossy,
".bus priority protection wrong for \(label)"
)
}
}
// MARK: - Helpers