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:
4 files changed
+85
-8
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
+12
@@ -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
|
||||
|
||||
Reference in new issue
Block a user