Skip to content

feat(#663): auto-recover MCP-orphaned sessions (watchdog force-fresh) - #664

Merged
olegbrok merged 5 commits into
mainfrom
fix/663-mcp-orphan-identity-verify
Jun 2, 2026
Merged

feat(#663): auto-recover MCP-orphaned sessions (watchdog force-fresh)#664
olegbrok merged 5 commits into
mainfrom
fix/663-mcp-orphan-identity-verify

Conversation

@olegbrok

@olegbrok olegbrok commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

What

Auto-recovery for the MCP-orphan / identity-unbound blackout (#663): when the daemon restarts it tears down the in-process :8890 MCP gateway, so every live agent's Mcp-Session-Id goes stale. Per the upstream CC bug anthropics/claude-code#60949 + #9608 (both closed as not planned), Claude Code never re-initializes an HTTP/SSE MCP transport after a 404 session-not-found — so the agent's pinky tools 404 for the rest of the session, and a plain --continue restart re-resumes the same dead transport. Only a force-fresh relaunch re-binds MCP. CC won't self-heal, so the recovery is ours.

3rd sibling of #612 (REPL dies) and #613 (poisoned transcript) — but touches none of the same code (tmux_session.py untouched), so it's standalone, not stacked.

Designed + reviewed with @murzik (full thread on #663; review on this PR).

How

Detection — the agent's own heartbeat is the probe. send_heartbeat is an MCP tool call, so a heartbeat executing proves the agent's real MCP client reached the current gateway generation. No prompt-injection / control-turn machinery needed.

  1. Bind ledger + gateway epoch (shared_mcp.py): a per-gateway-generation epoch (bumps on every app build — daemon restart and gateway crash-restart; the bump clears the ledger so a pre-restart bind never counts) + a per-agent last-success ledger written only from inside a tool handler (a stale client can reach the gateway and still 404, so arrival ≠ success).
  2. mcp_probe(nonce, launch_id) core pinky-self tool — explicit bind proof (for the phase-2 launch-time fast-path); records into the ledger.
  3. send_heartbeat now records a generic bind success on every call.
  4. GET /agents/{name}/mcp-bind-status — read-only ledger view; never touches the agent's MCP transport, so it works precisely when that transport is dead.
  5. Watchdog MCP-unbound recovery (session_watchdog.py, flag-gated mcp_recover, default OFF): a CONNECTED + heartbeat-enabled agent with no current-epoch MCP success sustained past a deadline (max(3× heartbeat_interval, 240s)) → force-fresh restart (api._watchdog_mcp_recover, mirrors admin_force_restart_agent; audited mcp_epoch_unbound; informational owner alert). This auto-restart bypasses the stale-save guard by calling ss.connect() directly, so the dead-MCP deadlock that needed a manual restart is handled automatically.

Review responses

  • Finding Hybrid Memory Architecture: Drop file backend, bridge to Claude Code native memory #1 (P2) — dropped the public restart-guard tools_unreachable bypass. !bound is absence-of-proof, not proof-of-unreachable (a healthy session that hasn't emitted its first post-epoch heartbeat also reads !bound), and _get_streaming_restart_guard is shared by many paths — so it could wrongly relax a save guard. The deadlock is already covered by the watchdog auto-recovery (above) + admin_force_restart_agent (manual escape hatch). A sustained-signal guard bypass is deferred to phase-2.
  • Finding Research Pipeline: Multi-agent peer-reviewed research briefs #2 (pre-fleet constraint) — tracked. The watchdog recovers on sustained absence of a current-epoch MCP success, not on a missed requested heartbeat; a busy/rate-limited-but-healthy agent that doesn't send_heartbeat for max(3× hb, 240s) after an epoch bump could be force-restarted. Acceptable for the barsik-only flag-gated soak; before fleet enablement, record heartbeat/probe requested_at and recover on missed requested probes (or include real prompt cadence/skip state in _watchdog_mcp_bind_status).

Safety rails (Brad OOO; deploying under Trust-L2 rails)

  • Flag-gated, default OFF → enable on barsik first to soak, then fleet. Merging/deploying ships dormant code.
  • Fail-safe — bind-status errors are swallowed; never kills a session on a diagnostic hiccup.
  • No false-trips — only heartbeat-enabled agents are checkable.
  • No deploy storm — sustained-outage deadline gives every agent the full window to re-bind after a gateway rebuild.
  • Bounded rate — per-agent post-recover grace + global 120s min-interval; a failed recover doesn't burn the limit.

Testing

  • tests/test_shared_mcp.py — ledger/epoch semantics.
  • tests/test_session_watchdog.py — 11 cases incl. sustained-deadline, heartbeat-disabled never trips, post-recover grace, global rate-limit, status-error swallowed, failed-recover-doesn't-burn-limit, end-to-end routing.
  • tests/test_pinky_self_tools.py — core 24 / all-gates 70 with mcp_probe.
  • Full CI green (the local nacl collection errors are a pre-existing local-env gap; CI has PyNaCl).

Deploy plan

Merge → cut release → update_and_restart → enable watchdog_config.mcp_recover on barsik only → soak (watch the bind ledger / no false recoveries) → fleet once clean.

Follow-up (phase 2)

Addresses #663. Siblings: #612, #613. Context: #606, #623.

🤖 Opened by Barsik

Oleg and others added 3 commits June 2, 2026 14:03
Primitive for detecting an identity-unbound resumed session (CC #60949/#9608:
CC won't re-init an MCP transport after a 404, so a daemon restart that tears
down the :8890 gateway leaves live agents' tools dead until a fresh relaunch).

- shared_mcp: per-gateway-generation epoch (bumps on every app build) + an
  in-memory per-agent bind ledger written only from inside the tool handler
  (a tool executing proves the agent's real MCP client traversed the current
  gateway; request arrival does not, since a stale client can still 404).
  Epoch bump clears the ledger so a pre-restart bind never counts as current.
- pinky-self: mcp_probe(nonce, launch_id) core tool — records a probe success
  and returns {agent, nonce, launch_id, gateway_epoch, observed_at}.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- shared_mcp.record_mcp_success(agent): generic (nonce-less) bind record;
  refreshes the current-epoch entry, preserving a prior probe's nonce.
- pinky-self send_heartbeat now records a bind success on every call — a
  heartbeat tool executing proves the agent's MCP client reached the current
  gateway, giving the watchdog a deterministic periodic liveness signal
  (independent of prompt injection) for heartbeat-enabled agents.
- api: GET /agents/{name}/mcp-bind-status — read-only bind ledger view
  (bound for current gateway generation? last nonce/launch_id/observed_at).
  Does not touch the agent's MCP transport, so it works when that is dead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The recovery net for the CC #60949 outage class: a daemon restart tears down
the :8890 gateway, the resumed CC client never re-inits its MCP transport, so
all pinky tools 404 until a force-fresh relaunch. The watchdog now detects and
heals this — using the agent's own heartbeat as the bind probe.

session_watchdog:
- WatchdogConfig.mcp_recover flag (default OFF — soak on one agent first).
- _evaluate_mcp_bind: for a CONNECTED, heartbeat-enabled agent with no MCP
  success recorded for the CURRENT gateway epoch sustained past a deadline
  (max(3x heartbeat_interval, 240s)) → force-fresh recover. Safety rails:
  heartbeat-disabled/quiet agents never trip; deadline measures a sustained
  outage (so a rebuilt gateway gives every agent the full window to re-bind
  via its next heartbeat — no deploy storm); per-agent post-recover grace +
  a global min-interval backstop (120s) against fleet restart storms; status
  errors are swallowed (never kill a healthy session on a diagnostic hiccup).

api: wire _watchdog_mcp_bind_status ({checkable, bound, heartbeat_interval})
and _watchdog_mcp_recover (force-fresh restart mirroring admin_force_restart_
agent — sets force_fresh_context_once so the relaunch drops --continue; audits
as mcp_epoch_unbound; informational owner alert on recovery).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread src/pinky_daemon/api.py Fixed
Comment thread src/pinky_daemon/api.py
)
try:
await ss.disconnect()
except Exception:
Comment thread src/pinky_daemon/api.py
# so any context-staleness gate is satisfied; preserve saved state.
try:
agents.bump_context_updated_at(agent_name)
except Exception:
Comment thread src/pinky_self/server.py
Comment on lines +40 to +45
from pinky_daemon.shared_mcp import (
LazyAgentName,
record_mcp_success,
record_probe_success,
resolve_lazy,
)
Oleg and others added 2 commits June 2, 2026 14:18
- test_shared_mcp: epoch bump changes id + clears ledger; probe/heartbeat
  record + status; nonce preserved within epoch, fresh across epoch; blank
  agent no-op; unknown agent unbound.
- test_session_watchdog: 11 cases — recover only after a sustained deadline;
  bound clears clock; heartbeat-disabled never trips; flag-off no-op;
  disconnected skip; post-recover grace; global rate-limit defers-then-allows;
  status-error swallowed; recover-failure doesn't burn the rate-limit;
  end-to-end routing through _evaluate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mcp_probe is a new core pinky-self tool, so core goes to 24 and (atop main's
#145 register_agent at 69) all-gates goes to 70.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@olegbrok
olegbrok force-pushed the fix/663-mcp-orphan-identity-verify branch from 506b698 to 284a46c Compare June 2, 2026 21:18
@olegbrok olegbrok changed the title feat(#663): auto-recover MCP-orphaned sessions (watchdog force-fresh + guard bypass) feat(#663): auto-recover MCP-orphaned sessions (watchdog force-fresh) Jun 2, 2026
@olegbrok
olegbrok merged commit 83d054f into main Jun 2, 2026
11 checks passed
@olegbrok
olegbrok deleted the fix/663-mcp-orphan-identity-verify branch June 2, 2026 21:28
olegbrok added a commit that referenced this pull request Jun 2, 2026
…te + R2 checkable) (#665)

* feat(#663): activate MCP-recover for low-cadence agents (R1 enable-gate + R2 checkable)

Two refinements that let `mcp_recover` be enabled on an agent whose
progress/transition watchdog is OFF (barsik's shape) — the activation
blockers found in the #664 post-merge review.

R1 — watchdog enable-gate (session_watchdog._evaluate):
`mcp_recover` is its own opt-in, independent of the master `cfg.enabled`
switch. The MCP-bind branch now runs before the `enabled` gate (return
early only when BOTH `enabled` and `mcp_recover` are off); the
progress/transition watchdog stays gated on `enabled`. During a
BOOTING/RECONNECTING transition the branch is a cheap no-op (connected
is False), so the transition branch still handles that wedge.

R2 — checkable signal (api._watchdog_mcp_bind_status): `checkable` was
`heartbeat_interval > 0`, which wrongly excludes wake-driven sidekicks
that carry interval==0 yet heartbeat in practice (barsik; Murzik
Finding #2). Now also true when the agent emitted a recent AGENT-ORIGIN
heartbeat (get_latest_agent_heartbeat — excludes synthetic
server_presence rows, so a wedged agent can't look checkable-and-bound).
Extracted as the pure, unit-tested helper `compute_mcp_checkable`.

Still flag-gated (mcp_recover default OFF). Tests: R1 enable-gate routing
+ progress-gated-off, R2 checkable policy matrix. 52 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#663): add transition-snap + mcp_recover guard test (Murzik nit)

Proves the R1 _evaluate reorder doesn't steal lifecycle transitions: with
enabled=True + mcp_recover=True and a RECONNECTING (connected=False) snap,
the MCP-bind branch no-ops and the transition branch still owns the wedge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants