fix(agent): a session id is an address - #45
Merged
Conversation
Every distinct session id in one directory resolved to the same session, and there was no way to ask for a new one. `SessionRepo::resume_or_create` matched on `cwd` first and consulted the caller's id only in the no-match branch, so once any session existed for a directory every `--session-id` pointed at it was silently discarded. Everything that opens a session in repo mode went through it — a bare `run`, `run --continue`, `serve` with or without `--session-dir` — so N ids collapsed to 1. Split the API into the two things it was conflating, and fix the precedence: - `open_or_create_id` — open exactly this id, or mint it under exactly this id. Never another session, and never by unique prefix (`find_path_exact`): resolving `abc` onto an existing `abcdef` would both return the wrong session and suppress creating the one asked for. - `resume_latest_or_create` — this cwd's most recent session. Now reached only via `--continue`. `--session-id` > `--continue` > fresh, identically in `run` and `serve` (`serve::SessionSelect`). `serve` gains `--continue` to spell what its default used to do implicitly. A bare launch now starts its own session rather than reattaching. Two shells in one repo previously drove the same store, and `append_new` is count-keyed, so neither could observe the other's writes and the transcript interleaved. Daemon: `session_cfg` rewrote every WS session into single-file mode purely to escape the cwd collapse. With ids taking precedence that workaround is gone, which fixes what it cost — daemon files were `<id>.jsonl` where the repo names `<created_at>_<id>.jsonl`, so `find_path` couldn't resolve them and a daemon session was listable but not openable by id. Sessions are now durable across a full daemon restart, not just a reap. Because the id is a routing key, `new_session` on an addressed session keeps it and archives the outgoing conversation into a sibling session (`parent` = the slot) instead of overwriting it — "start a new session" no longer doubles as "destroy the old one". Creating a genuinely new session over the daemon is a routing operation: connect with a new `?session_id=`. Also fixes exact-match id lookup, which compared an `_<id>.jsonl` suffix. Ids may legally contain `_`, so a lookup for `b` matched a session named `a_b`. Harmless for a convenience lookup; not harmless once it decides what an address resolves to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
Both found by running the shard under CI's own nextest profile and filter, which plain `cargo test` doesn't reproduce. A fresh `BufReader` per command over one pipe is a latent hang: it reads ahead, so bytes of the next frame land in a buffer that's then dropped, and the following read blocks forever on data already consumed. A wedged test never drops its `ChildGuard`, which leaves a `serve` running — the failure mode `.github/workflows/ci.yml` documents as hanging the step rather than failing it. One reader per child now, with an explicit `shutdown()` that closes stdin and reaps. `a_bare_serve_starts_fresh_while_continue_reattaches` asserted *which* session `--continue` picks. `updated_at` is second-granularity, so a prompted session and an empty one written inside the same second tie, and the tie breaks on directory order — it failed (and retry-failed) under shard parallelism. It now asserts the deterministic contract: `--continue` reattaches to a session already present rather than minting one. "Most recent wins" stays pinned by serve_resumes_newest_session_matching_cwd_not_globally_newest, which separates its candidates by cwd rather than by time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Every distinct session id in one directory resolved to the same session, and there was no way to ask for a new one.
SessionRepo::resume_or_creatematched oncwdfirst and consulted the caller's id only in the no-match branch:So once any session existed for a directory, every
--session-idpointed at it was silently dropped. Everything that opens a session in repo mode went through it — a barerun,run --continue,servewith or without--session-dir— so N ids collapsed onto 1.The rule
A session id is an address. It resolves to the session it names or to a brand-new one under exactly that name — never to some other session that merely shares the directory.
Split the API into the two things it was conflating:
open_or_create_idresume_latest_or_create--continuePrecedence, identical in
runandserve(serve::SessionSelect):servegains--continueto spell what its default used to do implicitly.Behavior changes
run/servestarts its own session instead of reattaching. Two shells in one repo previously drove the same store, andappend_newis count-keyed, so neither could observe the other's writes and the transcript interleaved.--continueis now the only implicit reattach.--session-idreopens an existing session rather than being ignored, making it idempotent — the right flag for a supervisedserve, where--continuedepends on whatever else touched the directory meanwhile.Daemon
session_cfgrewrote every WS session into single-file mode purely to escape the cwd collapse. With ids taking precedence that workaround is unnecessary, and dropping it fixes what it cost:<id>.jsonlwhere the repo names<created_at>_<id>.jsonl, sofind_path's_<id>.jsonllookup couldn't see them — a daemon session appeared inlist_sessionsbutswitch_sessionreported it missing.?session_id=Xreopens the real conversation.Because the id is a routing key,
new_sessionon an addressed session keeps it and archives the outgoing conversation into a sibling session (parent= the slot) instead of overwriting it — "start a new session" no longer doubles as "destroy the old one". Creating a genuinely new session over the daemon is a routing operation: connect with a new?session_id=.Known limit, documented in
serve_ws's module docs:switch_session/fork/clonemove this process's view while the routing key stays put, so the move isn't durable across a respawn. Re-keying was considered and rejected — two clients can share a session, and the target id may already be live, so re-keying either drags an unrelated client along or collides two threads onto one file.Also
Exact-match id lookup compared an
_<id>.jsonlsuffix. Ids may legally contain_(is_valid_session_id), so a lookup forbmatched a session nameda_b. Harmless while it only backed a convenience lookup; not harmless once it decides what an address resolves to. Now compares the parsed<id>component.Tests
New
crates/agent/tests/serve_session_addressing.rs(5 e2e) plus unit andrune2e coverage:--continuereattachesnew_sessionon an addressed session keeps the id and archives the old transcriptbdoes not resolve ontoa_b;abcdoes not resolve ontoabcdefThree existing tests pinned the old contract and were inverted or given
--continue;serve_websocket's filename assertions now resolve by id instead, which is the assertion that actually matters.Full
beyond-ai-agentsuite green locally (--no-fail-fast), clippy and dprint clean.ARCHITECTURE.mdand the README document the selection rule.🤖 Generated with Claude Code