Skip to content

fix(mcp-friend): observable + recoverable inbox delivery - #44

Merged
leondavi merged 8 commits into
masterfrom
v0.3.0
May 18, 2026
Merged

fix(mcp-friend): observable + recoverable inbox delivery#44
leondavi merged 8 commits into
masterfrom
v0.3.0

Conversation

@leondavi

Copy link
Copy Markdown
Owner

Summary

Four commits that make the PlutoMCPFriend's background delivery path
observable and surface its failure modes through pluto_health /
pluto_session. Motivation: today we hit a wedged HTTP listener and
two long-lived watcher subagents racing on the same inbox — the
adapter had no way to surface either condition to the agent.

  • 3154852 watchdog the background peek loop. PEEK_HARD_TIMEOUT_S=15s
    per peek RTT (via asyncio.wait_for) so a hung HTTP socket no longer
    silently stalls delivery. Tracks last_peek_ok / attempts /
    ok_count / last_error; one-shot stall warning per stall window;
    fires notifier.watcher_error on hard-timeouts.
  • 7eec2f1 surface unrecoverable state on repeated session-loss.
    After SESSION_LOST_GIVE_UP_AFTER=3 consecutive 401s the peek loop
    exits cleanly with _unrecoverable=True + reason and stops spinning
    the HTTP path. Streak resets on any successful peek. Honors the
    no-auto-reregister contract (commit 8d0ca13) — recovery is still
    --resume from snapshot.
  • 3029a4d wire it through pluto_health. New peek_loop block;
    unrecoverable=true also flips top-level agent_registered=false
    and sets recovery_hint with the relaunch command.
  • 65fe2a4 grace-windowed watcher slot occupancy. Fixes the
    "two Pluto inbox watcher subagents on the same inbox" symptom by
    keeping the watcher slot occupied for 5s after a slice returns; a
    looping subagent cancels its own pending eviction on re-entry, a
    peer subagent calling in the gap bounces with already_watching.
    Surfaced via new watchers block on both pluto_session and
    pluto_health. Role prompt updated to mandate
    watchers.active == 0 before spawning a new watcher Task.

Test plan

  • Smoke-tested all three failure modes against a stub client:
    healthy → ok_count grows; hung peek → stalled=true + last_error;
    repeated 401 → unrecoverable=true + loop exits cleanly;
    recovery → stall clears, ok_count resumes.
  • Smoke-tested grace-windowed dedupe: single slice visible during
    call, entry persists through grace window, peer call during
    grace bounces, looping caller cancels eviction and reports
    cumulative age, post-grace cleanup leaves no orphans.
  • Existing tests/test_mcp_friend.py skips locally (mcp SDK not
    installed in this Python) — CI should exercise it.
  • Manual: run PlutoMCPFriend --agent-id X with server down →
    pluto_health should report peek_loop.unrecoverable=true
    after ~3 backoff cycles.

🤖 Generated with Claude Code

leondavi and others added 4 commits May 16, 2026 20:57
A wedged HTTP socket on the server side could silently stall the
InboxManager peek loop forever — no new messages would land in the
buffer, watch_durable slices would return empty for the full
wait_timeout_s, and the agent had no way to distinguish "no traffic"
from "delivery is broken." (Exactly the symptom we hit when the
9202 listener wedged earlier today.)

- Hard-cap every peek RTT at PEEK_HARD_TIMEOUT_S=15s via
  asyncio.wait_for. asyncio cannot kill the underlying to_thread
  worker (Python limitation) but the timeout unblocks the loop so it
  can warn, back off, and keep retrying instead of hanging silently.
- Track last_peek_ok (monotonic + wall clock), attempts, ok_count,
  and last_peek_error so the loop's liveness is observable.
- Emit a single warn-level log per stall window (PEEK_STALL_WARN_S=10s
  since last success), re-armed once the loop recovers; fires
  notifier.watcher_error on every hard-timeout so MCP clients that
  enabled notifications see it.
- New public InboxManager.peek_loop_state() snapshot — read-only,
  consumed by the upcoming pluto_health additions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Honors the no-auto-reregister contract (commit 8d0ca13) while making
the failure mode observable instead of silent. Previously the peek
loop on a 401/session_not_found just slept for SESSION_RETRY_BACKOFF_S
and retried forever — agents saw "no messages arriving" with no way
to tell they were talking to a dead session.

- After SESSION_LOST_GIVE_UP_AFTER=3 consecutive session-lost peeks,
  the loop sets _unrecoverable=True with a reason string, fires
  notifier.watcher_error, logs at ERROR, and exits cleanly. We do
  NOT re-register — that's the documented MCP-friend contract;
  silent re-registration would mask identity loss when a human
  kicked the agent.
- A single successful peek resets the streak, so a transient blip
  doesn't accumulate across hours.
- New _unrecoverable / _unrecoverable_reason fields are exposed via
  peek_loop_state(), consumed by the upcoming pluto_health additions.

Recovery contract is unchanged: relaunch PlutoMCPFriend with
--resume to restore identity from the latest snapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the new InboxManager.peek_loop_state() into pluto_health so
agents can self-diagnose delivery problems without reading process
state or server logs.

New pluto_health.peek_loop block:
  alive, age_s, last_ok_at, last_error, attempts, ok_count,
  stalled, unrecoverable, unrecoverable_reason, interval_s,
  hard_timeout_s, stall_threshold_s

Failure → action mapping:
- peek_loop.unrecoverable=true → terminal session loss; also flips
  the top-level agent_registered=false and sets recovery_hint with
  the --resume relaunch command.
- peek_loop.stalled=true → HTTP listener wedged; sets recovery_hint
  pointing the user at the server.
- Healthy quiet inbox: alive=true, stalled=false, ok_count growing —
  distinguishable from "delivery wedged" for the first time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a parent Claude Code agent spawns a Pluto inbox-watcher subagent,
the subagent loops pluto_inbox_watch calls — each tool call lasts one
~60s slice, then exits and the subagent re-invokes. Per-tool-call
dedupe (already_watching: true) only catches *overlapping* slices, so
between slices a peer watcher subagent can sneak past, leaving two
long-lived watcher Tasks burning subagent budget on the same inbox.
(Observed: two "Pluto inbox watcher" subagents at 11m44s and 2m53s.)

This fixes the visibility gap without changing the dedupe contract:

- watch_durable now schedules a delayed eviction at slice end instead
  of discarding immediately. WATCHER_GRACE_S=5s. A looping subagent
  cancels its own pending eviction on re-entry, so a single long-
  lived watcher reports one stable slot with cumulative age.
- _watcher_started_at tracks wall-clock entry time, surviving across
  slices for the same caller.
- New InboxManager.active_watchers_snapshot() returns
  {active, ids, oldest_age_s, grace_s} — surfaced under the new
  ``watchers`` block in both pluto_session and pluto_health.
- Role prompt now mandates: before spawning a watcher Task, call
  pluto_session and bail if watchers.active >= 1. The grace window
  makes this check reliable even between slices.
- Legacy active_watchers list field on pluto_session is preserved
  (ids only) so existing callers keep working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@leondavi leondavi self-assigned this May 16, 2026
@leondavi leondavi added bug Something isn't working enhancement New feature or request labels May 16, 2026
leondavi and others added 4 commits May 17, 2026 02:24
Adds an opt-in one-message-per-call consumption path alongside the
existing batch drain. Pipeline / event-driven agents can now switch the
inbox into "single" mode and drive consumption explicitly via the new
pluto_pop tool — one notification → one pop → process → next pop —
without surprise bulk drains via piggyback on unrelated Pluto tool calls.

- InboxManager: delivery_mode toggle, pop_one(wait_s) with event-driven
  blocking, single-mode piggyback that attaches only the head message
  plus _pluto_inbox_remaining
- New tools: pluto_pop, pluto_set_delivery_mode; pluto_session surfaces
  delivery_mode
- Role connection prompt: new "Pipeline / event-driven work" section
  steering pipeline agents toward pluto_pop while keeping pluto_recv as
  the recommended default for turn-driven interactive work
- Docs: pluto-mcp-friend.md tools table + "How inbox messages reach the
  agent" updated with mode explanation and the canonical pop loop
- Tests: 13 new cases covering FIFO order, remaining counter, blocking
  wait, event-clear semantics, single-mode piggyback head-only, and the
  two new MCP tools

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…l guide

Adds §1.1 "Delivery modes — batch vs. single" to the technical guide
covering the new piggyback wrap shape, pluto_pop loop pattern, and
at-least-once semantics. Channels table now lists pluto_pop as a
per-message delivery option; configuration matrix records the runtime
delivery_mode toggle; end-to-end delivery sequence shows the
single-mode pop_one() ack path alongside the existing drain/piggyback
paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@leondavi
leondavi merged commit 1c4da65 into master May 18, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant