From f2770093001fa464bcf2a4b9e65352b338eab1c7 Mon Sep 17 00:00:00 2001 From: Oleg Date: Tue, 2 Jun 2026 19:29:18 -0700 Subject: [PATCH 1/2] feat(#663 ph2): probe-request ledger foundation (shared_mcp) Phase-2 active mcp_probe elicitation, design-stable foundation only: record_probe_request / get_probe_request + _probe_request_ledger, parallel to the success ledger. Tracks that the daemon ASKED an agent to prove its bind at launch (launch_id+nonce); a current-generation request with no matching success is the positive wedge signal that removes the healthy-idle-vs-wedged ambiguity. Cleared on epoch bump like the success ledger. get_probe_request computes current/fulfilled by matching launch_id under the live epoch. Launch-path directive injection (2a) + watchdog missed-probe trigger (2b) held pending Murzik design review. 8 new tests, 52 pass, ruff clean. Co-Authored-By: Claude Opus 4.8 --- src/pinky_daemon/shared_mcp.py | 76 +++++++++++++++++++++++++++++++++- tests/test_shared_mcp.py | 62 +++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 2 deletions(-) diff --git a/src/pinky_daemon/shared_mcp.py b/src/pinky_daemon/shared_mcp.py index bcaef535..69264321 100644 --- a/src/pinky_daemon/shared_mcp.py +++ b/src/pinky_daemon/shared_mcp.py @@ -157,6 +157,15 @@ def __radd__(self, other: str) -> str: _gateway_epoch: str = "" _probe_ledger: dict[str, dict] = {} +# Probe-*request* ledger (#663 phase-2). Records that the daemon ASKED an agent +# to prove its bind by calling mcp_probe at launch (a fresh launch_id+nonce +# injected into the wake context). Distinct from _probe_ledger, which only +# records SUCCESSES. A current-generation request with no matching success past +# a deadline is a positive wedge signal — the agent was told to prove binding +# and couldn't — which removes the healthy-idle-vs-wedged ambiguity that passive +# detection alone can't resolve. Per-agent: {launch_id, nonce, gateway_epoch, +# requested_at}. +_probe_request_ledger: dict[str, dict] = {} _ledger_lock = threading.Lock() @@ -164,10 +173,12 @@ def bump_gateway_epoch() -> str: """Assign a fresh gateway epoch (called once per gateway app build).""" global _gateway_epoch _gateway_epoch = uuid.uuid4().hex[:12] - # A new generation invalidates every prior bind — clear the ledger so a - # stale pre-restart entry can never be mistaken for a current-gen success. + # A new generation invalidates every prior bind AND any pending probe + # request (it targeted a launch in the prior generation) — clear both so a + # stale pre-restart entry can never be mistaken for current-gen state. with _ledger_lock: _probe_ledger.clear() + _probe_request_ledger.clear() return _gateway_epoch @@ -196,6 +207,67 @@ def record_probe_success(agent_name: str, nonce: str, launch_id: str = "") -> di return dict(entry) +def record_probe_request( + agent_name: str, launch_id: str, nonce: str, requested_at: float | None = None +) -> dict: + """Record that the daemon asked ``agent_name`` to prove its bind (#663 ph2). + + Called when a launch/resume wake context is *delivered* (commit path) with + an injected ``mcp_probe`` directive — NOT on the eager pre-build, or we would + log a request that never reaches the agent. Tagged with the live + gateway_epoch so the watchdog can tell a current-generation request (the + agent should have answered by now) from a stale one. Overwrites any prior + request for the agent (one outstanding launch at a time). + """ + if not agent_name: + return {} + entry = { + "launch_id": launch_id, + "nonce": nonce, + "gateway_epoch": _gateway_epoch, + "requested_at": requested_at if requested_at is not None else time.time(), + } + with _ledger_lock: + _probe_request_ledger[agent_name] = entry + return dict(entry) + + +def get_probe_request(agent_name: str) -> dict: + """Return the outstanding probe-request entry for an agent (or {}). + + ``current`` is True when the request was made for the LIVE gateway + generation (so a missing matching success is meaningful — a stale request + from a prior generation tells us nothing). ``fulfilled`` is True when the + success ledger holds a probe success for the SAME launch_id under the + current epoch, i.e. the agent answered THIS request. The watchdog treats a + current + unfulfilled request older than its deadline as a wedge signal. + """ + with _ledger_lock: + req = dict(_probe_request_ledger.get(agent_name, {})) + success = dict(_probe_ledger.get(agent_name, {})) + if not req: + return {} + current = bool(_gateway_epoch and req.get("gateway_epoch") == _gateway_epoch) + fulfilled = bool( + current + and success.get("gateway_epoch") == _gateway_epoch + and success.get("launch_id") + and success.get("launch_id") == req.get("launch_id") + ) + requested = req.get("requested_at") + return { + "agent": agent_name, + "launch_id": req.get("launch_id", ""), + "nonce": req.get("nonce", ""), + "gateway_epoch": req.get("gateway_epoch", ""), + "current_epoch": _gateway_epoch, + "current": current, + "fulfilled": fulfilled, + "requested_at": requested, + "age_sec": (time.time() - requested) if requested else None, + } + + def record_mcp_success(agent_name: str) -> None: """Record a generic successful MCP round-trip (e.g. a heartbeat). diff --git a/tests/test_shared_mcp.py b/tests/test_shared_mcp.py index d6e044c0..a5805915 100644 --- a/tests/test_shared_mcp.py +++ b/tests/test_shared_mcp.py @@ -15,9 +15,11 @@ bump_gateway_epoch, get_current_agent, get_gateway_epoch, + get_probe_request, get_probe_status, make_agent_name_resolver, record_mcp_success, + record_probe_request, record_probe_success, ) @@ -586,3 +588,63 @@ def test_unknown_agent_unbound(self): bump_gateway_epoch() st = get_probe_status("nobody-here") assert st["bound"] is False and st["observed_at"] is None + + +class TestProbeRequestLedger: + """#663 phase-2 — the daemon-side probe-REQUEST ledger (active elicitation).""" + + def test_no_request_is_empty(self): + bump_gateway_epoch() + assert get_probe_request("ghost") == {} + + def test_request_is_current_and_unfulfilled(self): + bump_gateway_epoch() + record_probe_request("alpha", "L1", "n1") + req = get_probe_request("alpha") + assert req["current"] is True + assert req["fulfilled"] is False # no success yet + assert req["launch_id"] == "L1" and req["nonce"] == "n1" + assert req["age_sec"] is not None + + def test_matching_success_fulfills_request(self): + bump_gateway_epoch() + record_probe_request("beta", "L2", "n2") + record_probe_success("beta", "n2", "L2") # agent answered THIS launch + assert get_probe_request("beta")["fulfilled"] is True + + def test_success_with_different_launch_id_does_not_fulfill(self): + # A leftover success from a prior launch must not satisfy a new request. + bump_gateway_epoch() + record_probe_success("gamma", "old", "L_old") + record_probe_request("gamma", "L_new", "n_new") + req = get_probe_request("gamma") + assert req["current"] is True and req["fulfilled"] is False + + def test_epoch_bump_clears_requests(self): + bump_gateway_epoch() + record_probe_request("delta", "L3", "n3") + assert get_probe_request("delta")["current"] is True + bump_gateway_epoch() # new generation + assert get_probe_request("delta") == {} # request cleared + + def test_stale_request_from_prior_epoch_not_current(self): + # Defensive: if a request somehow carries a prior epoch, it isn't current. + bump_gateway_epoch() + record_probe_request("epsilon", "L4", "n4", requested_at=1000.0) + # Simulate a generation change WITHOUT clearing by re-recording success + # under a new epoch only (the request keeps its old epoch tag). + e2 = bump_gateway_epoch() + record_probe_request("epsilon", "L5", "n5") # fresh, current-epoch request + req = get_probe_request("epsilon") + assert req["gateway_epoch"] == e2 and req["current"] is True + + def test_blank_agent_is_noop(self): + bump_gateway_epoch() + assert record_probe_request("", "L", "n") == {} + assert get_probe_request("") == {} + + def test_explicit_requested_at_preserved(self): + bump_gateway_epoch() + entry = record_probe_request("zeta", "L6", "n6", requested_at=4242.0) + assert entry["requested_at"] == 4242.0 + assert get_probe_request("zeta")["requested_at"] == 4242.0 From 84b655201d328241392aa30b77877c4cb3c1bb84 Mon Sep 17 00:00:00 2001 From: Oleg Date: Tue, 2 Jun 2026 19:41:34 -0700 Subject: [PATCH 2/2] =?UTF-8?q?feat(#663=20ph2):=20active=20mcp=5Fprobe=20?= =?UTF-8?q?elicitation=20=E2=80=94=20directive=20(2a)=20+=20corroboration?= =?UTF-8?q?=20(2b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Murzik design review. Removes the healthy-idle-vs-wedged ambiguity that keeps passive detection single-agent-soak-only, by having the agent POSITIVELY prove its MCP client re-bound — without ever letting a missed probe trigger a recovery on its own. 2a — launch-path directive (api._build_streaming_wake_context): For agents with mcp_recover on, inject a top-of-prompt mcp_probe directive (fresh launch_id+nonce) on every delivered (commit=True) launch/wake/restart. Records the request via record_probe_request BEFORE mutating wake_ctx, so a record failure leaves the normal text intact. Eager preview (commit=False) injects nothing and logs no request (its throwaway nonce could never be answered). Fully fail-safe — any error falls back to normal wake text, never blocks a launch. 2b — watchdog corroboration (session_watchdog._evaluate_mcp_bind): The bind-status fn now surfaces get_probe_request. When the existing passive guard (connected + checkable + bound=false + sustained) ALREADY decides to recover, the reason is enriched with the missed-probe detail iff the launch's request is current + unfulfilled + aged past DEFAULT_MCP_PROBE_DEADLINE (120s). Missed-probe NEVER triggers recovery alone, never manufactures checkable, never shortens the deadline, and a generic bound=true makes the agent healthy regardless of the specific directive. Murzik nits folded in: fulfillment requires launch_id AND nonce match (nonce is proof, not telemetry); ledger comment softened to "corroborating". Tests: shared_mcp request-ledger matrix, 2a injection (commit gating + failure fallback + nonce match), 2b corroboration gating. 623 pass across affected suites, ruff clean. Co-Authored-By: Claude Opus 4.8 --- src/pinky_daemon/api.py | 48 ++++++++++++ src/pinky_daemon/session_watchdog.py | 21 ++++++ src/pinky_daemon/shared_mcp.py | 20 +++-- tests/test_api.py | 71 ++++++++++++++++++ tests/test_session_watchdog.py | 107 +++++++++++++++++++++++++++ tests/test_shared_mcp.py | 9 +++ 6 files changed, 269 insertions(+), 7 deletions(-) diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index f50d96b3..5249ffc4 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -149,7 +149,9 @@ SHARED_MCP_PORT, SharedMcpManager, get_gateway_epoch, + get_probe_request, get_probe_status, + record_probe_request, ) from pinky_daemon.skill_loader import discover_all_skills, register_discovered_skills from pinky_daemon.skill_store import SkillStore @@ -2076,6 +2078,47 @@ def _build_streaming_wake_context( except Exception as e: _log(f"wake_context: failed to read restart manifest for {agent_name}: {e}") + # ── MCP bind-probe directive (#663 phase-2) ────────────────── + # For agents enrolled in MCP-orphan auto-recovery (per-agent + # mcp_recover flag), inject a top-of-prompt directive eliciting an + # mcp_probe round-trip, so a healthy agent POSITIVELY proves its MCP + # client re-bound to the current gateway generation instead of us + # inferring health from prolonged silence. Gated on commit=True: the + # connect-time rebuild is the delivered build whose text actually + # reaches the model, so its fresh nonce matches the registered request; + # the eager preview (commit=False) gets no directive and logs no request + # (a throwaway-nonce directive there could never be answered). Record + # the request BEFORE mutating wake_ctx so a record failure leaves the + # normal wake text intact. Fully fail-safe: any error falls back to the + # normal wake text with no request, so a probe bug can never block a + # launch. Placed last so it sits at the VERY TOP of wake_ctx (not buried + # under saved-state/channel/task text). + try: + if ( + commit + and agent + and WatchdogConfig.from_raw( + getattr(agent, "watchdog_config", None) + ).mcp_recover + ): + launch_id = uuid.uuid4().hex[:12] + nonce = uuid.uuid4().hex[:12] + record_probe_request(agent_name, launch_id, nonce) + probe_directive = ( + "⚠️ MCP BIND CHECK — FIRST, silently, before anything else: " + f"call mcp_probe(nonce='{nonce}', launch_id='{launch_id}') to " + "confirm your MCP transport re-bound to the current gateway, " + "then continue normally. Do not mention this unless it fails." + ) + wake_ctx = ( + f"{probe_directive}\n\n{wake_ctx}" if wake_ctx else probe_directive + ) + except Exception as e: + _log( + f"wake_context: mcp-probe directive injection failed for " + f"{agent_name}: {e}" + ) + return wake_ctx # Exposed for unit-test reach-in (#591 reason-gating verification). @@ -8312,6 +8355,10 @@ def _watchdog_mcp_bind_status(agent_name: str) -> dict: carry ``heartbeat_interval == 0`` but heartbeat in practice (#663 Murzik Finding #2 / R2). ``bound`` is true when a successful MCP round-trip has been recorded for the CURRENT gateway epoch (the heartbeat bind signal). + ``probe_request`` carries the #663 phase-2 active-probe state (whether the + agent answered the launch-time mcp_probe directive) — used by the watchdog + only to CORROBORATE/enrich an already-decided passive recovery, never to + trigger one on its own. """ agent = agents.get(agent_name) hb = int(getattr(agent, "heartbeat_interval", 0) or 0) if agent else 0 @@ -8334,6 +8381,7 @@ def _watchdog_mcp_bind_status(agent_name: str) -> dict: ), "bound": bool(st.get("bound")), "heartbeat_interval": hb, + "probe_request": get_probe_request(agent_name), } async def _watchdog_mcp_recover(agent_name: str, label: str, reason: str) -> None: diff --git a/src/pinky_daemon/session_watchdog.py b/src/pinky_daemon/session_watchdog.py index b3e30729..ad73f276 100644 --- a/src/pinky_daemon/session_watchdog.py +++ b/src/pinky_daemon/session_watchdog.py @@ -64,6 +64,11 @@ DEFAULT_MCP_UNBOUND_FLOOR = 240 # min deadline regardless of heartbeat interval DEFAULT_MCP_UNBOUND_HEARTBEAT_MULT = 3 # deadline >= this * heartbeat_interval DEFAULT_MCP_RECOVER_MIN_INTERVAL = 120 # global min secs between any two MCP recoveries +# #663 phase-2: a launch-time mcp_probe directive unanswered for this long is a +# *corroborating* wedge signal. Generous (LLM may take a few turns to act on the +# first-action directive). Only enriches an already-decided passive recovery — +# never triggers one, and never shortens the sustained-unbound deadline. +DEFAULT_MCP_PROBE_DEADLINE = 120 # checkable-via-history window (#663 / R2). An agent with heartbeat_interval==0 # (no scheduler-driven cadence) is still "checkable" if it emitted an # AGENT-ORIGIN heartbeat within this window — i.e. it is an actively-heartbeating @@ -630,6 +635,22 @@ async def _evaluate_mcp_bind( f"MCP transport unbound for current gateway epoch ~{int(unbound_for)}s " f"(no successful MCP round-trip; deadline {deadline}s) — force-fresh recover" ) + # #663 phase-2: CORROBORATE with the active-probe signal. The recovery is + # already decided by the passive sustained-unbound machinery above; this + # only enriches the reason/audit when the agent also ignored its + # launch-time mcp_probe directive past the probe deadline. It never + # triggers recovery on its own and never shortens the deadline. + probe = status.get("probe_request") or {} + if ( + probe.get("current") + and not probe.get("fulfilled") + and (probe.get("age_sec") or 0) >= DEFAULT_MCP_PROBE_DEADLINE + ): + reason += ( + f"; missed requested probe (launch_id={probe.get('launch_id')}, " + f"nonce={probe.get('nonce')}, ~{int(probe.get('age_sec') or 0)}s " + "unanswered) — corroborates wedge" + ) _warn("watchdog MCP-recovering %s: %s", snap.agent_name, reason) try: await self._mcp_recover_fn(snap.agent_name, snap.label, reason) diff --git a/src/pinky_daemon/shared_mcp.py b/src/pinky_daemon/shared_mcp.py index 69264321..d58c9e51 100644 --- a/src/pinky_daemon/shared_mcp.py +++ b/src/pinky_daemon/shared_mcp.py @@ -161,10 +161,11 @@ def __radd__(self, other: str) -> str: # to prove its bind by calling mcp_probe at launch (a fresh launch_id+nonce # injected into the wake context). Distinct from _probe_ledger, which only # records SUCCESSES. A current-generation request with no matching success past -# a deadline is a positive wedge signal — the agent was told to prove binding -# and couldn't — which removes the healthy-idle-vs-wedged ambiguity that passive -# detection alone can't resolve. Per-agent: {launch_id, nonce, gateway_epoch, -# requested_at}. +# a deadline is a *corroborating* wedge signal — used only to raise confidence / +# enrich audit inside the existing passive-unbound recovery guard, never as an +# independent kill switch (LLM non-compliance, prompt truncation, and +# post-builder delivery failure all mean a missed request alone isn't proof). +# Per-agent: {launch_id, nonce, gateway_epoch, requested_at}. _probe_request_ledger: dict[str, dict] = {} _ledger_lock = threading.Lock() @@ -238,9 +239,13 @@ def get_probe_request(agent_name: str) -> dict: ``current`` is True when the request was made for the LIVE gateway generation (so a missing matching success is meaningful — a stale request from a prior generation tells us nothing). ``fulfilled`` is True when the - success ledger holds a probe success for the SAME launch_id under the - current epoch, i.e. the agent answered THIS request. The watchdog treats a - current + unfulfilled request older than its deadline as a wedge signal. + success ledger holds a probe success for the SAME launch_id AND nonce under + the current epoch, i.e. the agent answered THIS specific request (the nonce + is part of the proof, not just telemetry). The watchdog treats a current + + unfulfilled request older than its deadline as a *corroborating* wedge + signal — and only ever when the passive signal already says bound=false, so + a generic MCP success that flips bound=true makes the agent healthy + regardless of whether the specific directive was answered. """ with _ledger_lock: req = dict(_probe_request_ledger.get(agent_name, {})) @@ -253,6 +258,7 @@ def get_probe_request(agent_name: str) -> dict: and success.get("gateway_epoch") == _gateway_epoch and success.get("launch_id") and success.get("launch_id") == req.get("launch_id") + and success.get("nonce") == req.get("nonce") ) requested = req.get("requested_at") return { diff --git a/tests/test_api.py b/tests/test_api.py index 09c2484a..fdb12fe3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -5355,6 +5355,77 @@ def test_central_wake_log_failure_does_not_advance_gate(self): assert "Grep daemon log for verdict_wedged_inputs" in out_retry +class TestWakeContextMcpProbeDirective: + """#663 phase-2 — the launch-time mcp_probe directive injection (2a).""" + + def _make_app(self, path: str): + from pinky_daemon.api import create_api + return create_api(max_sessions=10, default_working_dir="/tmp", db_path=path) + + def _extract_nonce(self, text: str) -> str: + import re + m = re.search(r"nonce='([0-9a-f]+)'", text) + return m.group(1) if m else "" + + def test_commit_true_injects_directive_and_records_request(self): + from pinky_daemon.shared_mcp import bump_gateway_epoch, get_probe_request + with tempfile.TemporaryDirectory() as tmpdir: + app = self._make_app(os.path.join(tmpdir, "t.db")) + app.state.agents.register( + "dymok", model="sonnet", watchdog_config={"mcp_recover": True} + ) + bump_gateway_epoch() # establish a live generation + out = app.state._build_streaming_wake_context("dymok", commit=True) + assert "MCP BIND CHECK" in out + assert out.lstrip().startswith("⚠️ MCP BIND CHECK") # at the very top + nonce = self._extract_nonce(out) + assert nonce + req = get_probe_request("dymok") + assert req and req["current"] is True and req["fulfilled"] is False + assert req["nonce"] == nonce # delivered directive nonce == registered + + def test_commit_false_preview_injects_nothing_and_records_no_request(self): + from pinky_daemon.shared_mcp import bump_gateway_epoch, get_probe_request + with tempfile.TemporaryDirectory() as tmpdir: + app = self._make_app(os.path.join(tmpdir, "t.db")) + app.state.agents.register( + "dymok", model="sonnet", watchdog_config={"mcp_recover": True} + ) + bump_gateway_epoch() + out = app.state._build_streaming_wake_context("dymok", commit=False) + assert "MCP BIND CHECK" not in out # no throwaway-nonce directive + assert get_probe_request("dymok") == {} # ledger untouched + + def test_no_directive_when_mcp_recover_off(self): + from pinky_daemon.shared_mcp import bump_gateway_epoch, get_probe_request + with tempfile.TemporaryDirectory() as tmpdir: + app = self._make_app(os.path.join(tmpdir, "t.db")) + app.state.agents.register("dymok", model="sonnet") # default: no mcp_recover + bump_gateway_epoch() + out = app.state._build_streaming_wake_context("dymok", commit=True) + assert "MCP BIND CHECK" not in out + assert get_probe_request("dymok") == {} + + def test_record_failure_falls_back_to_normal_text_no_request(self, monkeypatch): + # A failure inside the injection must never corrupt the wake text or + # block the launch — fall back to the normal text with no directive. + from pinky_daemon.shared_mcp import bump_gateway_epoch, get_probe_request + with tempfile.TemporaryDirectory() as tmpdir: + app = self._make_app(os.path.join(tmpdir, "t.db")) + app.state.agents.register( + "dymok", model="sonnet", watchdog_config={"mcp_recover": True} + ) + bump_gateway_epoch() + + def boom(*_a, **_k): + raise RuntimeError("ledger down") + + monkeypatch.setattr("pinky_daemon.api.record_probe_request", boom) + out = app.state._build_streaming_wake_context("dymok", commit=True) + assert "MCP BIND CHECK" not in out # record failed before prepend + assert get_probe_request("dymok") == {} # nothing recorded + + # ── Cross-agent memory authorizer (#145) ───────────────────── diff --git a/tests/test_session_watchdog.py b/tests/test_session_watchdog.py index d9385c3f..f6020466 100644 --- a/tests/test_session_watchdog.py +++ b/tests/test_session_watchdog.py @@ -7,6 +7,7 @@ from pinky_daemon.session_watchdog import ( DEFAULT_MCP_CHECKABLE_HEARTBEAT_MAX_AGE, + DEFAULT_MCP_PROBE_DEADLINE, DEFAULT_MCP_UNBOUND_FLOOR, SessionWatchdog, WatchdogConfig, @@ -751,6 +752,112 @@ async def rec(a, _l, _r): await wd._evaluate(snap, now + DEFAULT_MCP_UNBOUND_FLOOR + 5) # fires assert recovered == ["a"] + # ── #663 phase-2: missed-probe corroboration (CORROBORATES, never triggers) ── + + def _probe(self, *, current=True, fulfilled=False, age=200, lid="L1", nonce="n1"): + return {"current": current, "fulfilled": fulfilled, "age_sec": age, + "launch_id": lid, "nonce": nonce} + + @pytest.mark.asyncio + async def test_recovery_reason_includes_missed_probe(self, make_watchdog): + captured = [] + + async def rec(_a, _l, reason): + captured.append(reason) + + wd = make_watchdog( + mcp_bind_status_fn=lambda n: { + "checkable": True, "bound": False, "heartbeat_interval": 60, + "probe_request": self._probe(age=DEFAULT_MCP_PROBE_DEADLINE + 80)}, + mcp_recover_fn=rec, + ) + now = 5000.0 + st = _AgentState(mcp_unbound_since=now - 1000) # long unbound, past deadline + assert await wd._evaluate_mcp_bind( + _conn_snap(), st, WatchdogConfig(mcp_recover=True), now) is True + assert captured and "missed requested probe" in captured[0] + assert "L1" in captured[0] and "n1" in captured[0] + + @pytest.mark.asyncio + async def test_recovery_reason_omits_probe_when_fulfilled_or_fresh(self, make_watchdog): + # Fulfilled probe (agent answered) → recovery still fires on the passive + # signal, but the reason must NOT claim a missed probe. + captured = [] + + async def rec(_a, _l, reason): + captured.append(reason) + + wd = make_watchdog( + mcp_bind_status_fn=lambda n: { + "checkable": True, "bound": False, "heartbeat_interval": 60, + "probe_request": self._probe(fulfilled=True, age=999)}, + mcp_recover_fn=rec, + ) + now = 5000.0 + st = _AgentState(mcp_unbound_since=now - 1000) + assert await wd._evaluate_mcp_bind( + _conn_snap(), st, WatchdogConfig(mcp_recover=True), now) is True + assert captured and "missed requested probe" not in captured[0] + + @pytest.mark.asyncio + async def test_recovery_reason_omits_probe_when_not_yet_aged(self, make_watchdog): + captured = [] + + async def rec(_a, _l, reason): + captured.append(reason) + + wd = make_watchdog( + mcp_bind_status_fn=lambda n: { + "checkable": True, "bound": False, "heartbeat_interval": 60, + "probe_request": self._probe(age=DEFAULT_MCP_PROBE_DEADLINE - 1)}, + mcp_recover_fn=rec, + ) + now = 5000.0 + st = _AgentState(mcp_unbound_since=now - 1000) + assert await wd._evaluate_mcp_bind( + _conn_snap(), st, WatchdogConfig(mcp_recover=True), now) is True + assert captured and "missed requested probe" not in captured[0] + + @pytest.mark.asyncio + async def test_missed_probe_alone_never_recovers_when_not_checkable(self, make_watchdog): + # A blatantly-missed probe must NOT manufacture checkability — the + # passive gates still own the trigger. + recovered = [] + + async def rec(a, _l, _r): + recovered.append(a) + + wd = make_watchdog( + mcp_bind_status_fn=lambda n: { + "checkable": False, "bound": False, "heartbeat_interval": 0, + "probe_request": self._probe(age=99999)}, + mcp_recover_fn=rec, + ) + st = _AgentState(mcp_unbound_since=1.0) + assert await wd._evaluate_mcp_bind( + _conn_snap(), st, WatchdogConfig(mcp_recover=True), 1e9) is False + assert recovered == [] + + @pytest.mark.asyncio + async def test_bound_true_ignores_missed_probe(self, make_watchdog): + # Already bound = healthy, even if the specific probe directive went + # unanswered (a generic MCP success proved the transport). + recovered = [] + + async def rec(a, _l, _r): + recovered.append(a) + + wd = make_watchdog( + mcp_bind_status_fn=lambda n: { + "checkable": True, "bound": True, "heartbeat_interval": 60, + "probe_request": self._probe(age=99999)}, + mcp_recover_fn=rec, + ) + st = _AgentState(mcp_unbound_since=500.0) + assert await wd._evaluate_mcp_bind( + _conn_snap(), st, WatchdogConfig(mcp_recover=True), 2000.0) is False + assert recovered == [] + @pytest.mark.asyncio async def test_evaluate_mcp_branch_runs_when_watchdog_disabled(self, make_watchdog): """R1 (#663): mcp_recover is its own opt-in — the MCP-bind branch fires diff --git a/tests/test_shared_mcp.py b/tests/test_shared_mcp.py index a5805915..529642df 100644 --- a/tests/test_shared_mcp.py +++ b/tests/test_shared_mcp.py @@ -612,6 +612,15 @@ def test_matching_success_fulfills_request(self): record_probe_success("beta", "n2", "L2") # agent answered THIS launch assert get_probe_request("beta")["fulfilled"] is True + def test_matching_launch_id_wrong_nonce_does_not_fulfill(self): + # The nonce is part of the proof (Murzik #663-ph2): a success for the + # same launch but a different nonce must not count as answering THIS + # request, else the nonce is mere telemetry. + bump_gateway_epoch() + record_probe_request("eta", "L7", "n7") + record_probe_success("eta", "WRONG_NONCE", "L7") + assert get_probe_request("eta")["fulfilled"] is False + def test_success_with_different_launch_id_does_not_fulfill(self): # A leftover success from a prior launch must not satisfy a new request. bump_gateway_epoch()