MCP: ReplayingToolResultObserver.turnIDResolver permanently nil — SEC-07 dual-write + ME-04 channel dead in prod #20

Closed
opened 2026-05-12 19:04:42 +00:00 by KofTwentyTwo · 1 comment
KofTwentyTwo commented 2026-05-12 19:04:42 +00:00 (Migrated from github.com)

Summary

ReplayingToolResultObserver.turnIDResolver is permanently nil in production. The observer's record(...) guards guard let turnId = await turnIDResolver() else { logger.debug(...); return } and every call returns at the guard. SEC-07's documented dual-write (raw vs sanitized bytes) and ME-04's BoundedAsyncChannel<ReplayEnvelope> drain Task are silently dead. The "AGENT-10 four-seam contract" is exercised only by applicationWillFinishLaunching setup; in production, the seam is empty.

Evidence

  • File: App/AppDelegate.swift:621 — production wiring passes turnIDResolver: { nil } (verified at current HEAD)
  • File: App/MCP/ReplayingToolResultObserver.swift:62-69 — guard returns at first line when resolver returns nil
  • Verified: turnIDResolver is never reassigned after orchestrator construction (installAgent, AppDelegate:1671). No production write site, no var declaration, no .attach(...) analogue
  • The orchestrator does still write .toolResultFull to ReplayLog directly at AgentOrchestrator.swift:584, so replay rows exist — but those rows carry the post-sanitize, post-8KB-cap bytes (see CRIT-2)

Root cause / hypothesis

The pre-orchestrator wiring path passes a placeholder { nil } resolver, with the expectation that installAgent will later attach a real resolver pulling from the orchestrator's currentTurn?.id. The attach never happens. Same shape as today's calibration bug: probes see channel + drain wired; structural-composition tests assert observer gets record(...) calls; production reality is the guard returns at the first line and the channel stays empty.

Recommended fix

After installAgent constructs the orchestrator, mutate ReplayingToolResultObserver's resolver. The observer is a class — make turnIDResolver a var and add an attach(_ resolver:) method analogous to ConfirmationPresenterHolder.attach(_:). Alternatively, build the observer inside installAgent rather than MCPRuntimeWiring.build so the orchestrator reference is in scope at construction time.

Decide on shape: await per tool dispatch to hop to orchestrator actor, OR push active TurnID into a lock-protected slot the observer reads sync (similar to InProcessConfirmationCache).

Test

  • Behavioral: after a tool dispatch in a live turn, assert the ReplayEnvelope channel received a tool-result envelope
  • Assert turnIDResolver returns non-nil after installAgent completes

Source

  • Audit: .planning/audit-2026-05-12/mcp-agent-audit.md (2026-05-12) — finding CRIT-1

Cross-references

  • Related: #21 (CRIT-2 tool-result double-cap) — fixing this dissolves the dual-write redundancy
## Summary `ReplayingToolResultObserver.turnIDResolver` is permanently `nil` in production. The observer's `record(...)` guards `guard let turnId = await turnIDResolver() else { logger.debug(...); return }` and every call returns at the guard. SEC-07's documented dual-write (raw vs sanitized bytes) and ME-04's `BoundedAsyncChannel<ReplayEnvelope>` drain Task are silently dead. The "AGENT-10 four-seam contract" is exercised only by `applicationWillFinishLaunching` setup; in production, the seam is empty. ## Evidence - File: `App/AppDelegate.swift:621` — production wiring passes `turnIDResolver: { nil }` (verified at current HEAD) - File: `App/MCP/ReplayingToolResultObserver.swift:62-69` — guard returns at first line when resolver returns nil - Verified: `turnIDResolver` is never reassigned after orchestrator construction (`installAgent`, AppDelegate:1671). No production write site, no `var` declaration, no `.attach(...)` analogue - The orchestrator does still write `.toolResultFull` to ReplayLog directly at `AgentOrchestrator.swift:584`, so replay rows exist — but those rows carry the post-sanitize, post-8KB-cap bytes (see CRIT-2) ## Root cause / hypothesis The pre-orchestrator wiring path passes a placeholder `{ nil }` resolver, with the expectation that `installAgent` will later attach a real resolver pulling from the orchestrator's `currentTurn?.id`. The attach never happens. Same shape as today's calibration bug: probes see channel + drain wired; structural-composition tests assert observer gets `record(...)` calls; production reality is the guard returns at the first line and the channel stays empty. ## Recommended fix After `installAgent` constructs the orchestrator, mutate `ReplayingToolResultObserver`'s resolver. The observer is a class — make `turnIDResolver` a `var` and add an `attach(_ resolver:)` method analogous to `ConfirmationPresenterHolder.attach(_:)`. Alternatively, build the observer inside `installAgent` rather than `MCPRuntimeWiring.build` so the orchestrator reference is in scope at construction time. Decide on shape: await per tool dispatch to hop to orchestrator actor, OR push active TurnID into a lock-protected slot the observer reads sync (similar to `InProcessConfirmationCache`). ## Test - Behavioral: after a tool dispatch in a live turn, assert the `ReplayEnvelope` channel received a tool-result envelope - Assert `turnIDResolver` returns non-nil after `installAgent` completes ## Source - Audit: `.planning/audit-2026-05-12/mcp-agent-audit.md` (2026-05-12) — finding CRIT-1 ## Cross-references - Related: #21 (CRIT-2 tool-result double-cap) — fixing this dissolves the dual-write redundancy
KofTwentyTwo commented 2026-05-13 11:51:51 +00:00 (Migrated from github.com)

Closed by v0.1 cluster merges on 2026-05-13. Develop now at a8c7f1a0.

Closed by v0.1 cluster merges on 2026-05-13. Develop now at `a8c7f1a0`.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: KofTwentyTwo/Jarvis#20