fix(mcp): v0.1 CRIT cluster — turnID resolver wired + tool-result double-cap #157

Merged
KofTwentyTwo merged 2 commits from fix/mcp-crit-v0.1 into develop 2026-05-13 11:31:46 +00:00
2 Commits
Author SHA1 Message Date
james.maesandClaude Opus 4.7 d73cfc6627 fix(mcp): wire turnIDResolver post-installAgent — closes #20
MCPRuntimeWiring.build constructs ReplayingToolResultObserver with a
`{ nil }` placeholder resolver because the AgentOrchestrator that owns
the in-flight TurnID doesn't yet exist (the orchestrator consumes the
dispatcher chain we return). Before this fix, the resolver was a `let`
set once at init, so installAgent had no production write site to flip
it. Every observer.record(...) call returned at the guard
`guard let turnId = await turnIDResolver() else { return }`, and the
four-seam AGENT-10 contract (SEC-07 dual-write + ME-04 channel +
drain Task + ReplayLog writer) stayed dead for the process lifetime.

Fix:
  - ReplayingToolResultObserver.turnIDResolver becomes a lock-protected
    slot (OSAllocatedUnfairLock<ResolverBox> — same pattern as
    InProcessConfirmationCache in JarvisMCP, async-safe in Swift 6
    contexts unlike NSLock). attach(turnIDResolver:) replaces the slot;
    record(...) reads via withLock + invokes the resolver.
  - AgentOrchestrator gains a public actor-isolated `currentTurnID()`
    accessor over `currentTurn?.id`. One actor hop per tool dispatch
    is acceptable (tools fire 1–3× per turn).
  - AppDelegate.installAgent calls observer.attach(...) right after
    constructing the orchestrator, with `{ orchestrator?.currentTurnID() }`
    (weak capture, mirroring the broker/presenter lifetime invariant).
    A `systemLogger?.warning` fires if the observer isn't the expected
    concrete type (defense for future refactors).

Tests added to MCPRuntimeWiringTests:
  - test_attach_turnIDResolver_flipsObserverFromDeadToLive — pre-attach
    record(...) drops; post-attach record(...) emits two envelopes
    correlated to the attached TurnID.
  - test_attach_replayLogReceivesSECDualWriteCorrelatedToTurnID —
    end-to-end through the production observer wiring: observer built
    with { nil }, attach(...) flips, record(...) drives the channel,
    drain Task writes to a real ephemeral ReplayLog. Reads the SQLite
    `events` table directly to assert exactly two `tool_result_full`
    rows landed for the post-attach toolUseId, both correlated to the
    attached TurnID, with zero rows for the pre-attach call.

Refs audit-2026-05-12 CRIT-1 (.planning/audit-2026-05-12/mcp-agent-audit.md).
Related: #21 (the dual-write was redundant in part because the
dispatcher already 8-KB-capped what would have been the "full" raw
side; that's fixed separately, but the SEC-07 audit trail is the
non-redundant value of dual-write — pre-sanitize vs post-sanitize
content for "did the model ever see content X?" forensics).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 17:39:48 -05:00
james.maesandClaude Opus 4.7 4e88f0fe4c fix(mcp): remove redundant inner tool-result cap — closes #21
ToolResultPacker.pack was capping bytes that had already been capped
at the MCP dispatcher boundary by SanitizeForModel.prepareForBoundary,
making the doc-comment lie ("full blob still flows to ReplayLog ...
observability is not lossy") provably wrong. The orchestrator's
.toolResultFull replay row carried the already-capped 8 KB bytes, not
the original helper output, and the second cap was redundant.

Fix: turn ToolResultPacker.pack into a pass-through decoder. The MCP
dispatcher's prepareForBoundary call (sanitize + 8 KB head-truncate
with the …[tool-result-truncated at 8192 bytes] marker) is the sole
cap site. ReplayLog rows now carry exactly the dispatcher-prepared
bytes — never less, never re-marked. The packer's API stability is
preserved (Packed.{modelFacing, fullBytes, wasCapped, omittedByteCount}
all still present); wasCapped reports informationally from the size
signal and omittedByteCount is always zero post-fix.

Doc-comment updated to describe the new contract truthfully. Tests
updated:
  - ToolResultPackerTests: TP2 / TP5 renamed to assert the packer
    no longer adds the [TRUNCATED: ...] marker (the dispatcher's
    …[tool-result-truncated at 8192 bytes] marker is the only one).
  - OrchestratorSubmitTests:
    - OS6 renamed to test_OS6_replayAndModelSeeDispatcherPreparedBytes,
      asserting both replay and the model see the dispatcher's prepared
      bytes (no double-cap, no double-marker).
    - OS6b is the new #21 regression: a dispatcher that returns exactly
      8 KB drives a persisted ReplayLog row of exactly 8 KB (verified
      by opening the SQLite file directly) — not 4 KB or less.

Refs audit-2026-05-12 CRIT-2 (.planning/audit-2026-05-12/mcp-agent-audit.md).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 17:39:25 -05:00