Skip to content

v0.2.9: self-snapshot + restore-from-file, recovered_from_file status, coordination metrics - #41

Merged
leondavi merged 12 commits into
masterfrom
v0.2.9
May 10, 2026
Merged

v0.2.9: self-snapshot + restore-from-file, recovered_from_file status, coordination metrics#41
leondavi merged 12 commits into
masterfrom
v0.2.9

Conversation

@leondavi

@leondavi leondavi commented May 10, 2026

Copy link
Copy Markdown
Owner

Summary

Picks up everything from the closed v0.2.8 PR (#40 — spec contracts, watcher lifetime, stale-agent/token-leak fixes) and adds the v0.2.9 work.

New: self-snapshot + restore-from-file

Two protocol ops, end-to-end:

  • `snapshot_self` — Pluto returns a `.plut` JSON payload + a markdown recovery prompt the agent persists to disk.
  • `restore_from_snapshot` — agent presents the `.plut` after a fresh register; Pluto overlays attributes, custom_status, subscriptions, and reattaches every still-held lock with its original fencing token.

Wired through every layer:

Layer What changed
Erlang protocol `OP_SNAPSHOT_SELF`, `OP_RESTORE_FROM_SNAPSHOT` constants
Erlang msg_hub `capture_snapshot/1`, `restore_from_snapshot/2` APIs
Erlang session `handle_snapshot_self/1`, `handle_restore_from_snapshot/2`, `build_recovery_prompt/1`
Erlang HTTP listener `POST /agents/snapshot_self`, `POST /agents/restore_from_snapshot`
Python TCP client `PlutoClient.snapshot_self`, `restore_from_snapshot`, `save_snapshot_files`
Python HTTP client `PlutoHttpClient.snapshot_self`, `restore_from_snapshot`, `save_snapshot_files`
MCP tools `pluto_snapshot_self`, `pluto_restore_from_snapshot`
Bash launchers `PlutoAgentFriend.sh --restore `, `PlutoMCPFriend.sh --restore `

Both launchers validate the `.plut` upfront, auto-derive `--agent-id` from the snapshot, and reject mismatched IDs (otherwise every snapshot lock would silently end up in `lost_locks`).

New agent status `recovered_from_file`

Distinct from `recovered` (Pluto remembered you within the grace period). Propagated through every routing/listing/broadcast/publish/inbox path so the new status is treated as "active" — messages flow normally, just visible to monitoring.

Earlier v0.2.8 work folded in

  • Explicit `unregister` op so selftest agents leave cleanly.
  • Four-state agent lifecycle: `connected` / `disconnected` (grace) / `disconnected_timeout` (tombstone) / `recovered` (within-grace reconnect) / `recovered_from_file` (snapshot-restore).
  • New counters `agents_unregistered_clean`, `coordination_requests`.
  • `PlutoServer.sh --stats` dashboard splits Coordination vs Maintenance traffic and notes the selftest-agent count on Total agents.

Snapshot scope (what is and isn't restored)

Field Restored on `recovered_from_file`
agent_id yes — claimed via prior register
attributes yes — overlay merge
custom_status yes
subscriptions yes
held_locks each ref re-checked: still held → `reclaimed_locks`; released since snapshot → `lost_locks`
inbox no — relies on Pluto's normal grace-period delivery

Recovery markdown auto-injection into the LLM's system prompt is not yet shipped — the launcher only validates, registers, and calls `restore_from_snapshot`. The agent reads the `<agent_id>-recovery.md` itself (or you paste it into turn 1).

Tests

  • 7 new eunit integration tests in `pluto_snapshot_tests.erl` covering capture, state preservation, lock fencing tokens, restore + status flip, reclaim/lost-lock accounting, unregistered-restore rejection, and full HTTP round-trip.
  • Full suite: 134/134 green (127 existing + 7 new).
  • Bash launcher mismatch guard sanity-checked manually.

Docs

  • `docs/guide/pluto-mcp-friend.md`, `pluto-agent-friend.md`, `tcp-connection.md` updated with snapshot/restore sections.
  • Each guide now states explicitly what the launcher does today vs what the agent must do itself (load the recovery `.md`).

Version

Bumped to `0.2.9` across `VERSION.md`, `pluto.hrl`, `pluto.app.src`, `rebar.config`.

Commits

  • `93b1d2f` v0.2.8 base — `unregister` op, lifecycle states, coordination metrics
  • `5677557` v0.2.9 core — snapshot/restore + `recovered_from_file` + tests
  • `e0cd970` launcher wiring — `--restore` flag in both bash launchers, plumbed into Python entries
  • `cf6fbec` doc accuracy — guides now describe what `--restore` actually does today

Test plan

  • `rebar3 eunit` — 134/134 passing on v0.2.9
  • Server rebuilt and restarted as Pluto v0.2.9
  • Bash mismatch guard rejects `--agent-id` that disagrees with the `.plut`
  • Manual: take a snapshot via MCP tool, kill the agent, relaunch with `--restore`, verify `recovered_from_file` in `pluto_list_agents` and `reclaimed_locks` matches expectation
  • Manual: `./PlutoServer.sh --stats` shows the new Coordination/Maintenance request split

🤖 Generated with Claude Code

leondavi and others added 12 commits May 5, 2026 00:40
Orchestrators driving idle agents reported re-spawn gaps every ~5 min
(5 iterations × 60 s default per call) where messages queued unread
between subagent cycles. The 5-iteration cap was a Pluto design choice,
not a Claude Code constraint — the only platform constraint is the
600 s stream watchdog, which is satisfied by per-call timeout, not by
total iteration count.

Changes:
- Default iterations 5 → 15 (15 × 60 s = 15 min subagent lifetime)
- New --iterations CLI flag on PlutoMCPFriend so orchestrators can tune
  per-deployment without code edits
- Threaded iterations through PlutoMCPServer and every prompt builder
  (build_connection_block, build_watch_prompt_body, build_role/protocol/
  guide_prompt_body)
- Updated tests to lock in the new default and added two tests pinning
  the custom-iterations behavior so regressions surface fast

Trade-off: longer subagent lifetime trades a slightly larger subagent
budget for a 3× reduction in respawn-gap latency. Smaller is still
available via --iterations N for memory-constrained deployments.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related correctness bugs in agent lifecycle:

1. Stale agents accumulating across restarts
   pluto_persistence saved every agent (incl. long-disconnected) and
   restored them all marked disconnected. With no TTL, agents from days
   or weeks ago kept reappearing in pluto_msg_hub:list_agents_detailed/0
   after each restart, confusing callers of GET /agents?detailed=true.

   Fix: new ?DEFAULT_AGENT_STALE_AFTER_MS (7 days) + agent_stale_after_ms
   pluto_config key. Filter applied at snapshot save (don't grow
   unboundedly) and snapshot restore (don't resurrect ancient entries).
   No runtime ETS pruning — restart is the natural cleanup cycle.

2. HTTP token leak on unregister_agent
   The unregister handler released the agent's name from the registry
   and deleted its session record but left the HTTP session token alive
   in ?ETS_HTTP_SESSIONS. Because /agents/poll, /agents/peek, /agents/ack
   resolve token -> agent_id and then drain the inbox by agent_id,
   any orphaned client could keep polling with the stale token and
   consume messages destined for whoever next claimed the same agent_id.

   Fix: call evict_http_sessions(AgentId) in both the unregister and
   grace-expired handlers so unregister atomically invalidates all
   access tokens. Safe for grace-period reconnects, which use session_id
   (not the token) for identity resumption — the reconnecting client
   gets a fresh token issued by do_register_http_with_session.

   Affected callers of the leaky path:
     - pluto_session:cleanup/1 (TCP session close)
     - pluto_session:execute_admin OP_ADMIN_DISCONNECT
     - pluto_heartbeat.erl TCP heartbeat timeout

Tests added (TODO: run rebar3 eunit before merging):
  - src_erl/test/pluto_stale_agents_tests.erl — pure-function tests of
    the drop_stale_agents/3 filter (boundary, default 7-day, non-record
    pass-through, empty list)
  - src_erl/test/pluto_token_leak_tests.erl — integration tests proving
    old token gets 404 on peek/ack after unregister, and cannot drain
    messages addressed to a new owner that claimed the same agent_id

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Establishes a one-time session-wide "spec contract" the Orchestrator
broadcasts at session bootstrap (lock protocol, queue rule, no-emoji,
test command, release locks). Subsequent task_assigned dispatches
reference it by spec_id via the new spec_ref field instead of inlining
~40 lines of boilerplate per task — saving ~1-2K tokens per dispatch.

- protocol.md: new §4.12 spec_contract message, §7 walkthrough,
  spec_ref field on §4.1 task_assigned.
- roles/orchestrator.md: bootstrap step + concrete broadcast/dispatch
  curl examples + clarification-request handling for unseen spec_ref.
- roles/specialist.md + 7 other worker roles: cache spec_contract by
  spec_id, apply on task_assigned with matching spec_ref, ask for
  re-broadcast when missing.

Also documents in docs/guide/pluto-mcp-friend.md that --agent-id is
the agent's identity in Pluto's distributed system, that Pluto
auto-suffixes when the requested name is taken, and that mid-session
role activation is via the /pluto-role-<name> MCP slash command.
PlutoServer.sh --stats hits the existing TCP "stats" op (no
registration required) and renders a colour dashboard from
pluto_stats:get_summary/0 — messages sent/received/broadcast
with avg msg/sec, msg/hour, msg/day computed against uptime;
locks acquired/released/expired/renewed/waited; deadlocks,
agents (cumulative + live), and a top-10 agents-by-activity
table. server_stats.py is a thin TCP client mirroring
server_info.py's shape; --json mode emits the raw payload.
…P port mismatches

Two bugs the wizard hit on macOS (default bash 3.2):

- `${ans,,}` is bash 4+ only. macOS default is 3.2, so the y/n prompt
  in offer_to_start_server (and the parallel one in PlutoInstall.sh)
  errored with "bad substitution" and dropped the user out of the
  flow. Replaced with a portable `tr '[:upper:]' '[:lower:]'`.

- check_pluto_reachable said "OFFLINE" whenever the configured HTTP
  port had no listener, even though the daemon was alive on its TCP
  control port (and on a stale HTTP port). The typical case: the
  user edits config/pluto_config.json to bump host_http_port from
  9201 to 9202 but the daemon is still bound to 9201. Now the
  wizard probes the TCP control port and 9201/9202; if any of them
  respond it tells the user the daemon is alive but on a different
  HTTP port and prints the exact restart command.
…g/min

Extrapolating 2 messages over 3 minutes to "898 msg/day" is misleading,
not informative. Now any rate whose window exceeds the current uptime
prints "n/a (uptime <1h)" / "n/a (uptime <1d)" instead of a fabricated
projection. Also adds avg msg/min, the most useful short-window rate
during normal multi-agent sessions.
Previously the watcher only respawned when the in-flight Task itself
ended (messages, N iterations empty, or watchdog). Drains via
turn-start pluto_recv or _pluto_inbox piggyback didn't trigger a
respawn, so a still-running watcher could finish its cycle and leave
the agent with no listener until the next drain noticed.

New rule in build_connection_block: any drain — watcher fire,
pluto_recv, or piggyback — MUST be followed by an immediate watcher
respawn in the same turn. /pluto-check inherits the rule too.

Adds /pluto-watch-stop slash command + build_watch_stop_prompt_body()
for the watcher_stop kill switch: a session-level flag the agent
honours to disable auto-respawn (turn-driven pluto_recv only).
/pluto-watch implicitly clears the flag on resume.
…021/v023

Adds 4 unit tests in tests/test_mcp_friend.py and one capabilities
assertion:

- test_watch_stop_prompt_engages_kill_switch — /pluto-watch-stop body
  must name watcher_stop, forbid spawning new Tasks, allow the
  in-flight one to drain, point at the resume slash, and emit the
  exact "watcher_stop engaged" ack.
- test_check_prompt_respawns_watcher_after_drain — /pluto-check is a
  drain, so the body must include the respawn instruction with the
  watcher_stop exception.
- test_connection_block_drain_respawn_rule — the always-on role block
  must spell out all three drain paths (watcher fire, pluto_recv,
  _pluto_inbox piggyback) and document watcher_stop + /pluto-watch-stop.
- test_watch_prompt_clears_kill_switch — /pluto-watch must explicitly
  clear watcher_stop on resume.
- test_capabilities_register — extended to require pluto-watch-stop.

Also fixes test_v021_http_sessions and test_v023_features so they
auto-discover ports from config/pluto_config.json (with
PLUTO_HOST / PLUTO_PORT / PLUTO_HTTP_PORT env-var overrides). They
hardcoded 9000/9001 and silently failed against any server using the
current 9200/9202 defaults — pre-existing breakage from the v0.2.6
port bump that this surface change finally pulls into the green.

All 148 unit + integration tests now pass; the 2 remaining failures
in test_404_hints.py (not_found vs unknown_route response shape on
two URL patterns) are an unrelated pre-existing server bug.
…tuses, coordination_requests counter

- Add explicit `unregister` protocol op so agents leave cleanly (selftest
  agents now show in Disconnected rather than Live after test completes)
- Add four agent lifecycle statuses: connected, disconnected (grace period),
  disconnected_timeout (tombstone after grace expires), recovered (reconnected
  within grace period); all routing updated to treat recovered == connected
- selftest: each check sends unregister at end; check_direct_message
  registers B cleanly before A sends to it
- Add agents_unregistered_clean counter incremented on explicit unregister op
- Add coordination_requests counter (register/unregister, locks, messaging,
  tasks, pub/sub) vs maintenance (ping, stats, queries, admin, ack)
- Stats dashboard: Registered (Total) label, Unregistered (clean) row,
  Requests section split into Coordination/Maintenance/Total, selftest agent
  note on Total agents line

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

- Add `snapshot_self` and `restore_from_snapshot` protocol ops (TCP + HTTP)
  so an agent can persist its coordination state to a `.plut` JSON file
  plus a markdown recovery prompt and restore them on a fresh process
- Add `recovered_from_file` agent status, propagated through routing,
  listing, find_agents, and broadcast/publish/inbox-delivery paths
- pluto_msg_hub: `capture_snapshot/1` returns agent_id, session_id,
  attributes, custom_status, subscriptions, and held locks (with
  fencing tokens); `restore_from_snapshot/2` overlays state, splits
  snapshot locks into reclaimed_locks / lost_locks
- Session handler builds a stepwise recovery prompt for the agent
  (reload CLAUDE.md, audit reclaimed locks, drain inbox, etc.)
- New HTTP routes `/agents/snapshot_self` and `/agents/restore_from_snapshot`
- Python client (TCP + HTTP) gains `snapshot_self`, `restore_from_snapshot`,
  and `save_snapshot_files` convenience methods
- Two new MCP tools: `pluto_snapshot_self`, `pluto_restore_from_snapshot`
- 7 new eunit integration tests in pluto_snapshot_tests.erl;
  full suite: 134/134 green
- Docs: pluto-mcp-friend, pluto-agent-friend, tcp-connection updated
  with snapshot/restore sections, lock-reclaim semantics, when-not-to-use
  guidance, and Python + bash usage examples
- Version bumped to 0.2.9 in VERSION.md, pluto.hrl, pluto.app.src,
  rebar.config

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

Both launchers now accept --restore <plut> and apply it automatically after
register, so a recovered identity is in place before the wrapped LLM gets
its first turn. The agent_id stored inside the .plut is authoritative —
when --agent-id is omitted the launcher auto-derives it from the snapshot;
when both are passed and disagree the launcher exits with a clear error
rather than silently sending all snapshot locks into lost_locks.

PlutoAgentFriend
- bash: new --restore flag, validates the .plut, auto-derives agent_id,
  threads --restore through to the python wrapper
- pluto_agent_friend.py: new --restore CLI arg, restore_path constructor
  param, _apply_restore_snapshot helper that loads the JSON and calls
  PlutoConnection.restore_from_snapshot post-connect
- pluto_connection.py: new restore_from_snapshot(plut) method delegating
  to PlutoHttpClient.restore_from_snapshot

PlutoMCPFriend
- bash: new --restore flag, same .plut validation + auto-derive logic,
  threads --restore into write_mcp_json
- write_mcp_json: appends --restore argv when set so .mcp.json launches
  the python entry with the snapshot path
- pluto_mcp_friend.py: new --restore CLI arg, plumbed into PlutoMCPServer
- server.py: new restore_path constructor arg + _restore_from_file_blocking
  helper invoked from _lifespan after register; logs reclaim/lost counts

Help text and existing docs already describe the flag (added in v0.2.9).
Erlang test suite: 134/134 still green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The earlier draft overstated what the launchers do — it claimed they
prepend the recovery markdown to the LLM's system prompt. That part
is not yet wired; the launcher only validates the .plut, registers,
calls restore_from_snapshot, and logs reclaim/lost counts.

- pluto-mcp-friend.md, pluto-agent-friend.md: explicit step-by-step of
  what the launcher does today, plus a callout that the recovery .md
  is still loaded by the user / agent on its own
- Both guides clarify that --agent-id is auto-derived from the .plut
  and that mismatches are rejected fail-fast (not silently demoted to
  lost_locks)
- tcp-connection.md: short pointer to the launcher path so the Python
  API section doesn't leave the reader thinking restore is API-only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@leondavi
leondavi merged commit d2067c2 into master May 10, 2026
2 checks passed
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.

1 participant