Skip to content

fix(agent): a session id is an address - #45

Merged
jaredLunde merged 2 commits into
mainfrom
jared/session-id-addressing
Aug 15, 2026
Merged

fix(agent): a session id is an address#45
jaredLunde merged 2 commits into
mainfrom
jared/session-id-addressing

Conversation

@jaredLunde

Copy link
Copy Markdown
Contributor

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_create matched on cwd first and consulted the caller's id only in the no-match branch:

match self.list()?.into_iter().find(|m| m.cwd == cwd) {
    Some(meta) => self.open_id(&meta.id),   // <-- `id` never consulted
    None => { /* only here does the caller's id get used */ }
}

So once any session existed for a directory, every --session-id pointed at it was silently dropped. 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 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_id open exactly this id, or mint it under exactly this id
resume_latest_or_create this cwd's most recent session — now reached only via --continue

Precedence, identical in run and serve (serve::SessionSelect):

--session-id <id>   →  address one session (open-or-create, idempotent)
--continue          →  this cwd's most recent
neither             →  a fresh session

serve gains --continue to spell what its default used to do implicitly.

Behavior changes

  • A bare run/serve starts its own session instead of 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. --continue is now the only implicit reattach.
  • --session-id reopens an existing session rather than being ignored, making it idempotent — the right flag for a supervised serve, where --continue depends on whatever else touched the directory meanwhile.

Daemon

session_cfg rewrote 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:

  • Daemon files were <id>.jsonl where the repo names <created_at>_<id>.jsonl, so find_path's _<id>.jsonl lookup couldn't see them — a daemon session appeared in list_sessions but switch_session reported it missing.
  • Sessions are now durable across a full daemon restart, not just a reap: ?session_id=X reopens the real conversation.

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=.

Known limit, documented in serve_ws's module docs: switch_session/fork/clone move 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>.jsonl suffix. Ids may legally contain _ (is_valid_session_id), so a lookup for b matched a session named a_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 and run e2e coverage:

  • an addressed session wins over an unrelated session in the same directory
  • distinct ids in one directory are distinct sessions, each reopening its own transcript
  • addressing is idempotent across restarts, with no duplicate session file
  • a bare launch starts fresh while --continue reattaches
  • new_session on an addressed session keeps the id and archives the old transcript
  • b does not resolve onto a_b; abc does not resolve onto abcdef

Three 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-agent suite green locally (--no-fail-fast), clippy and dprint clean. ARCHITECTURE.md and the README document the selection rule.

🤖 Generated with Claude Code

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>
@cursor

cursor Bot commented Aug 15, 2026

Copy link
Copy Markdown

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>
@jaredLunde
jaredLunde merged commit 68a6f49 into main Aug 15, 2026
11 checks passed
@jaredLunde
jaredLunde deleted the jared/session-id-addressing branch August 15, 2026 22:02
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