buzz-acp: resume channel sessions across a restart, and name the channel on session/new - #6088
buzz-acp: resume channel sessions across a restart, and name the channel on session/new#6088jhgaylor wants to merge 3 commits into
Conversation
…id, forwarded from ACP _meta (#775) A chat harness like buzz-acp keeps its channel→session map in memory and opens `session/new` again for every channel after a restart; through the `fountain acp` gateway that was a new conversation and a new sandbox per restart — every hosted deploy (#774). - `conversations.channel_id` (opaque, client-supplied). `POST /api/conversations` with `channel_id` resumes the latest live conversation for the same user + agent + vault + channel (200, `meta.resumed: true`) instead of opening a new one (201); terminated/failed ones are past resuming and a new one takes over the binding. Without `channel_id` nothing changes. - `fountain acp` reads `_meta.channelId` on `session/new`, forwards it, and hands the resumed conversation back as the session id. - The hosted buzz-acp is built from jhgaylor/buzz@83eda69 (block/buzz#6088: `_meta.channelId` on session/new + session store) via a new optional `buzz-acp.source` the publish workflow honours; release name buzz-acp-v0.5.14-fountain.1. Delete the file and repin to an upstream tag once it merges. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
Operator +1 on this. We run a fleet of single tenant Buzz relays (hachiflow) with managed agents supervised by Buzz Desktop, and harness restarts are not an edge case at that layer, they are routine ops: config changes, desktop relaunches, host reboots, moving a harness between nodes. In one four day stretch our telemetry showed three agents restarting in lockstep seven times, and channel sessions averaging barely more than one turn before being discarded. Every one of those restarts was an agent re-learning its channel from a small context window because the channel to session map lived only in process memory. The forget-and-fall-back rule is the right shape: a resume that cannot happen never costs the turn, so the worst case is exactly today's behavior, and the best case removes the single biggest source of agent amnesia we see in practice. The _meta.channelId addition quietly matters for hosted harnesses too. In a container or pod the state dir is ephemeral unless someone thinks to mount it, so a gateway that keys sessions by (agent pubkey, channelId) in its own store can resume across a node move even when sessions-.json did not survive. One small ask from the containerized side: a README note that BUZZ_ACP_STATE_DIR should point at a mounted volume when the harness does not live on a laptop, since the /.buzz-acp default will silently vanish on every deploy otherwise. Would love to see this land. |
|
Tested the current head Live restart result
That verifies the production Automated verification
Separate existing reply-path blockerThe first live turn also reproduced the known Hermes/Buzz publication gap: Hermes attempted the correct Two remaining author actions before merge:
|
83eda69 to
af535a6
Compare
|
Thanks both — addressed in
@ScaleLeanChris thanks for the end-to-end run against real Hermes. I was using Openclaw so that's more data |
9d5c53c to
9390060
Compare
|
(Re-signed the docs commit — now |
…ock/buzz#6088) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…heck and the sync (#782) * chore(buzz-acp): repin the fork to the DCO-signed commit of block/buzz#6088 The PR's single commit was amended with Signed-off-by to satisfy Block's DCO check (83eda69a -> af535a64, identical tree). The old sha is now unreachable from any branch on the fork, so the pin follows it before GitHub can GC it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(buzz-acp): follow the fork to 9390060 (README docs commit on block/buzz#6088) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(acp): settle/1 tolerates a peer that stopped between the alive check and the sync The server stops the peer the moment it reports {:done, _}; :sys.get_state on it can hit noproc. That is the settled state, not a failure — it flaked partition 2 on 2026-08-17. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…nel on session/new A harness restart — a desktop relaunch, a config-change restart, or a hosted harness moved between nodes on a deploy — emptied the in-memory channel → session map, and the next mention in every channel got a fresh session/new. For an agent whose session *is* its workspace (a sandbox per session, as with the `fountain acp` gateway) that discarded the channel's memory and files each time; the base prompt tells the agent to "resume silently after a session restart", which assumes the opposite. Two changes: * `SessionStore`: a small JSON file (`<state-dir>/sessions-<pubkey>.json`, atomic writes) remembering channel → session id. Before opening a new session for a channel with no live one, the pool `session/load`s the remembered id when the agent advertised `loadSession` at initialize; any failure forgets the id and falls back to `session/new`, so a resume that cannot happen never costs the turn. Leaving a channel and `!rotate` forget the entry. `--state-dir` / `BUZZ_ACP_STATE_DIR` (default `<cwd>/.buzz-acp`), `--no-resume-sessions` / `BUZZ_ACP_NO_RESUME_SESSIONS` to opt out. * `session/new` now carries `_meta.channelId` and `_meta.channelType` (`AcpClient::session_new_with_origin`; `session_new_full` is unchanged and delegates). The prompt names the channel only as text; an agent or gateway that wants to key sessions by channel — to resume the same conversation after this harness restarts even when its own state directory is not persistent — had nothing machine-readable to key on. Absent for heartbeat sessions. Tests: store round-trip/malformed/disabled; client sends channelId/type and merges into existing _meta; loadSession capability recorded; session/load request shape and tolerance of the pre-response replay; error surfaces; config defaults and opt-out. Not covered here: a scripted end-to-end through the pool's get-or-create path (the pool has no scripted-agent test harness today). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…container volume note Reviewers asked for the README configuration table to list the two settings introduced with session resumption, and for an operator note that the default <cwd>/.buzz-acp state dir is ephemeral in containers and must be pointed at mounted persistent storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
9390060 to
40c06e7
Compare
|
🤖 Thanks for taking on #5342. Durable ACP continuity is useful and needed, and I verified that the basic production path works: The resumed turn used the same session ID and did not send a second I am requesting changes because the durable receipt is currently underspecified and is not kept consistent with the session lifecycle. This creates security/configuration regressions and new stale/corrupt-state failure modes on the exact restart path the PR introduces. 1. Scope the receipt to the complete session identityThe current coordinate is effectively: It does not bind the session to:
The same agent identity can run in multiple communities. With a colliding channel UUID and shared state directory, one community can select another community's remembered session ID. A restart after a model, prompt, runtime, or permission change can also load an incompatible old session. The load branch does not run the fresh-session path that applies the desired model and permission mode. Please use a versioned receipt with a full namespace and compatibility fingerprint, for example: {
"version": 1,
"scope": {
"relay": "canonical relay/community identity",
"ownerPubkey": "...",
"agentPubkey": "...",
"channelId": "..."
},
"sessionId": "...",
"compatibility": {
"runtime": "...",
"agentCommandHash": "...",
"model": "...",
"permissionMode": "...",
"systemPromptHash": "...",
"mcpConfigHash": "..."
}
}Hash sensitive or large configuration rather than storing prompt contents. If scope or compatibility does not match, do not load the old session. 2. Make RAM and durable state one lifecycle operationSeveral transitions mean “the next turn must use a fresh session.” The current code often invalidates only the in-memory map while leaving the receipt on disk. The next dispatch can therefore reload the session that was intentionally discarded. Idle Please centralize lifecycle operations: Every intentional fresh-session transition must atomically invalidate both RAM and durable state through the same API. 3. Distinguish definitive and indeterminate load failuresThe current resume path removes the receipt for every I reproduced this through the real Production sent: and replaced A timeout, broken pipe, process exit, or provider overload is not proof that the remembered session is gone. The load may even have succeeded remotely while Buzz timed out locally, creating two competing sessions. Please use a typed result:
4. Preserve stream origin on
|
Addresses review feedback on block#6088. The durable channel -> session map was a plain cache, and several paths that deliberately abandon a session left the receipt behind for the next turn to resume. Lifecycle. `invalidate_for_fresh_session` is now the single seam for "the next turn must not reuse this session": `!rotate` (idle and busy), a model switch (idle and busy), a token/turn-limit rotation, a cancel, and a failed cleanup all clear RAM and disk together. Agent death (`invalidate_all`) deliberately does not — the harness still wants the conversation back when the agent respawns, and a session the agent truly lost is forgotten by `session/load` failing. Without this a `!rotate` during an in-flight turn, or any MaxTokens rotation, resumed the very session that was discarded, and an idle model switch silently no-oped. Scope and compatibility. The file records the (relay, agent pubkey) pair it belongs to and is ignored otherwise, so one agent identity serving several communities cannot resume across them. Each receipt records the model and permission mode the session was created under and is refused when either changed: both are resolved from the `session/new` response, which `session/load` does not return, so resuming across such a change would silently run the old configuration. Nothing else is fingerprinted — the system prompt, team instructions, MCP list and memory are delivered per turn, and invalidating on those would wipe every channel on a prompt tweak. Failure classification. A JSON-RPC error is the agent answering, so the receipt is dropped and a fresh session opened. A timeout, broken pipe, or mid-load exit is not an answer: the receipt survives and the turn fails retryably rather than opening a session that could compete with one that loaded remotely. Bounded at three consecutive attempts. Stream origin. The resume path passed `None` for channel type, so a resumed public stream got `BUZZ_GIT_ORIGIN_AGENT_NAME` instead of `BUZZ_GIT_ORIGIN_CHANNEL_ID` and lost git provenance across a restart. It now uses the same resolved type as `session/new`. Store durability. Every write re-reads and merges under the lock, so two overlapping harnesses cannot lose each other's channels to a stale snapshot. Directory 0700, file 0600, unique `create_new` temp file, fsync of file and parent, size and version bounds on parse. Observer replay. `session/update`s replayed during `session/load` no longer reach the owner Activity feed as fresh output; everything else on the wire during a load still does. Tests. Causal coverage through the real `run_prompt_task` against a scripted ACP agent and an on-disk store: happy restart (one `session/load`, no second `session/new`), definitive vs indeterminate failure, a configuration change refusing the receipt, origin parity between fresh and resumed, and the lifecycle seam. Verified by mutation — disabling persistence, resume, origin wiring, or replay suppression each fails a test. Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
|
Thanks @ravarora2 — this was a genuinely useful review. Most of it is now addressed in Taken2. RAM and durable state as one lifecycle operation. You were right, and it was worse than "the receipt can go stale." Concretely, on the old head:
There is now a single seam, One deliberate exception: the 3. Definitive vs indeterminate. Adopted, and your framing ("the load may have succeeded remotely while Buzz timed out locally") is the part that convinced me — the risk isn't just a destroyed receipt, it's two live sessions. 4. Stream origin. Confirmed and fixed. 5. Overlapping processes. Fixed, though not with a lease. Every write now re-reads the file inside the lock, applies the single operation to what's actually on disk, and renames a fresh temp file over it — so your A/B repro keeps both records. Last-writer-wins on a single channel is retained deliberately: that's the correct resolution, since it's the session most recently created for it. A lease or per-record storage would buy nothing more for this failure mode; say the word if you disagree. Hardening. Directory 6. Causal tests — and a correction. My PR body said "the pool has no scripted-agent test harness today." That was wrong;
On your mutation point: I checked these the same way. Reverting the origin wiring, disabling the Replay and the observer feed. Fixed here rather than deferred. Partly taken — item 1I've taken the scoping half. The file records the I've taken a two-field compatibility check rather than the full fingerprint, on a principle I'd like to argue for: the receipt records exactly what the harness applies at The other fields I'm not including: If you think a specific one of those does change behavior in a way Docs updated to describe all of the above. |
…hardening) Cleaned integration branch, not a raw PR head: - base: block#6682 SQLite/WAL store + durable processed_events dedupe - ported: block#6088 owner-only 0600/0700 perms + repair-on-open - 2 new regression tests assert file MODE (negative control: 7/2 fail without) Source branch: integration/session-store (based on origin/main f6e6617)
…WAL + processed_events) Base chosen over block#6088 on verified grounds: - block#6682 merges clean vs origin/main (merge-tree rc=0); block#6088 conflicts (rc=1) - block#6682 durably dedupes relay events (processed_events table); block#6088 leaves duplicate-after-restart unguarded (queue.rs untouched) - block#6682 has worker-slot keying (ContextKey::for_worker); block#6088 has a flat channel->session map with no worker discriminator Upstream base: f6e6617. PR head: c11e582 Signed-off-by: Michael Fethe <mfethe1@gmail.com>
…ed from block#6088) block#6682 opens the SQLite store with a bare Connection::open, leaving the DB at the process umask (commonly 0644 = world-readable). Bindings are not secrets but they name channels, agent pubkeys and workspace paths. Ported block#6088's permission discipline onto block#6682's SQLite base: - store directory created 0700 - database created 0600 BEFORE SQLite opens it, so its bytes are never briefly world-readable (SQLite derives -wal/-shm modes from the main file) - an existing loose-permission store is REPAIRED on open, so the fix reaches deployments created before this landed Two regression tests assert the MODE, not the content. block#6682's shipped session_store_file_contains_ids_only passes even when the file is world-readable, so it cannot catch this class of regression. Negative control: removing the hardening fails exactly these 2 tests (7 pass / 2 fail); restoring returns 9/9. Upstream base: f6e6617 block#6682 head: c11e582 block#6088 reference: session_store.rs:324 (0o600), :350 (0o700), :520-521 (test) Signed-off-by: Michael Fethe <mfethe1@gmail.com>
…hardening) Cleaned integration branch, not a raw PR head: - base: block#6682 SQLite/WAL store + durable processed_events dedupe - ported: block#6088 owner-only 0600/0700 perms + repair-on-open - 2 new regression tests assert file MODE (negative control: 7/2 fail without) Source branch: integration/session-store (based on origin/main f6e6617) Signed-off-by: Michael Fethe <mfethe1@gmail.com>
Problem
A harness restart — a desktop relaunch, a config-change restart, or a hosted harness moved between nodes on a deploy — empties the in-memory channel → session map (
SessionState.sessions), and the next mention in every channel gets a freshsession/new. For an agent whose session is its workspace (a sandbox per session, as with thefountain acpgateway) that discards the channel's memory and files every time. The base prompt tells the agent to "resume silently after a session restart", which assumes the opposite.Change
1.
SessionStore+session/loadon restart. A small JSON file (<state-dir>/sessions-<pubkey>.json) remembers, per channel, the session id plus the model and permission mode it was created under. Before opening a new session for a channel with no live one, the poolsession/loads the remembered id when the agent advertisedloadSessionatinitialize. Resumed sessions get fresh delivery state and no zero-usage baseline (their prior usage isn't zero).The receipt is a scoped lifecycle record, not a cache:
(relay, agent pubkey)pair it belongs to and is ignored otherwise, so one agent identity serving several communities can't resume across them.modelandpermissionModeare the two things the harness applies from thesession/newresponse and cannot apply atsession/load, so a receipt is refused when either has changed. Nothing else is fingerprinted — the system prompt, team instructions, MCP list and memory are delivered per turn, and invalidating on those would wipe every channel's history on a prompt tweak.invalidate_for_fresh_sessionis the single seam for "the next turn must not reuse this session":!rotate(idle and busy), a model switch (idle and busy), a token/turn-limit rotation, a cancel, and a failed cleanup clear RAM and disk together. Agent death (invalidate_all) deliberately does not — the harness still wants the conversation back when the agent respawns.0700, file0600, uniquecreate_newtemp file, fsync of file and parent, size and version bounds on parse.session/updates replayed duringsession/loadno longer reach the owner Activity feed as fresh output; everything else on the wire during a load still does.A resume that cannot happen never costs the turn: every definitive failure falls back to
session/newfor that channel.--state-dir/BUZZ_ACP_STATE_DIR(default<cwd>/.buzz-acp)--no-resume-sessions/BUZZ_ACP_NO_RESUME_SESSIONSto opt out (previous behavior)2.
_meta.channelId/_meta.channelTypeonsession/new(AcpClient::session_new_with_origin;session_new_fullunchanged, delegates withNone). The prompt names the channel only as text in[Context];sessionTitlecarries the channel name and is empty for DMs. An agent or gateway that wants to key sessions by channel — to resume the same conversation after this harness restarts even when its own state directory isn't persistent — had nothing machine-readable to key on. Absent for heartbeat sessions.Tests
Causal coverage through the real
run_prompt_task, driving a scripted ACP agent against an on-diskSessionStore:session/new→ persist → process 2 with an emptySessionState→ exactly onesession/loadfor the same id and zerosession/new;BUZZ_GIT_ORIGIN_CHANNEL_IDas a fresh one;Unit coverage:
session_store(round-trip, remove, malformed, version and scope mismatch, the two-writer lost-update repro,0600/0700modes, compatibility matching);acp(channelId/channelTypein_metamerging withsessionTitle,session_new_fullsending no origin,loadSessioncapability recorded/left false,session/loadrequest shape and replay tolerance, errors surfacing asErr, replay suppression on the observer feed);config(state-dir default, explicit, opt-out).Verified by mutation: disabling persistence, the resume lookup, the origin wiring, or replay suppression each fails a test.
cargo test -p buzz-acp --lib813/0,cargo fmt --all -- --check, workspacecargo clippy --all-targetsclean.Compatibility
Default-on, but strictly additive on the wire: agents that don't advertise
loadSessionnever seesession/load;_metamembers are ignored by agents that don't read them (assessionTitlealready is).Related work
Fixes #5342 (channel → session map is in-memory only; every restart opens
session/newand, on Codex, a new cloud thread).Two open PRs also add a
session_store.rs; naming them up front:session/loadbehindloadSession— same shape as part 1 heremainand fork CI never ran there. This one is smaller, default-on with--no-resume-sessions, keyed per agent pubkey, and adds part 2 (_meta.channelId/channelType). If #2633 lands first I'll drop the store and rebase part 2 onto it.buzz-acp-only slice with tests.Adjacent, not overlapping: #4480 (
session/resumefor live MCP swaps on thesame session id — argues resume over load to dodge the 60s replay timeout on
long histories; worth weighing as the resume verb here), #6045 (durable
pending-event journal across restart — complementary; happy to align the
state-dir convention if it lands first), #4721 (identity receipts, explicitly
excludes restart continuity).
Points raised in #2633's review, and in @ravarora2's review of this PR, are
addressed in
313d5fd4c: replayedsession/updates are suppressed on theobserver feed for the duration of a
session/load; load failures are splitinto definitive (forget) and indeterminate (retain, retry, bounded); and the
store re-reads and merges under the lock so overlapping harness processes
cannot lose each other's channels. See that
comment for
the full response, including the one item — a full configuration fingerprint —
where I'm arguing for a narrower scope.
🤖 Generated with Claude Code