Skip to content

fix(session): let every surface find a transcript filed under another group - #455

Merged
0xKT merged 3 commits into
mainfrom
fix/gateway_session_group_lookup
Sep 17, 2026
Merged

0xKT merged 3 commits into
mainfrom
fix/gateway_session_group_lookup

Conversation

@LivXue

@LivXue LivXue commented Sep 16, 2026

Copy link
Copy Markdown
Member

Summary

A conversation started in the TUI was listed by the served page and then could not be opened: clicking it produced an empty transcript.

SessionManager.session_path looks for a transcript in this process's own group, and falls back to searching the other groups when it is not there. That fallback was gated on project_slug, so only a surface that groups by launch directory ever ran it. The gateway groups by channel and never did -- yet list_sessions scans every group, so the page offered a conversation filed under a launch directory's slug and then looked for it under sessions/tui/. session.resume missed the file, fell through to its fresh-mint branch, and handed the reader an empty transcript under a new id.

The write verbs resolve through the same function, so the miss was not only a failed read. A rename, a pin or a turn from the page filed a second transcript under the channel group, splitting one conversation across two files:

sessions/-srv-alpha/abc.jsonl -> 2 messages
sessions/tui/abc.jsonl        -> 1 messages   (written by the gateway)

Ungating the search is necessary but not sufficient, and the second half is the part worth reviewing. The glob matches on the chat_id, and a chat_id names no channel: cli:<id> and tui:<id> are two conversations, so an ungated search let a tui: save adopt the cli: file and append to it. A candidate is now taken only once its own metadata key says it is this session. A transcript carrying no key predates that field and is still adopted on the stem alone, which is the case the search was originally added for.

Matching on the stored key rather than on the directory name is also what makes this correct on Windows. key_from_path tells a slug from a channel by a leading -, which holds only for POSIX paths; a Windows launch directory slugs to C--Windows-system32, with no leading separator to read.

Root cause was confirmed against a control rather than by inspection: two tui: sessions, same handler, same manager, differing only in which group holds the file. The one under sessions/tui/ resumed with its 26 messages; the one under a slug minted a fresh empty session.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

uv run pytest tests/test_session_project_grouping.py -q      2 failed, 32 passed
uv run pytest tests/test_cli_session_commands.py -q          43 passed
uv run ruff check raven/session/manager.py tests/test_session_project_grouping.py
                                                             All checks passed!
uv run ruff format --check <same two files>                  2 files already formatted
uv run ty check raven/session/manager.py                     All checks passed!

The 2 failures in test_session_project_grouping.py are present on unmodified main in this Windows checkout and are not touched by this change: test_project_dir_is_not_rewritten_by_a_later_run_elsewhere and test_slug_collides_for_a_separator_and_an_underscore both build fixtures from POSIX paths such as /srv/alpha, which Path renders as \srv\alpha on Windows.

Regression scope was measured rather than asserted. Running pytest tests/ -k "session or transcript or workdir or resume" (about 1085 tests) with and without the change gives 372 failures before and 372 after, with identical failure sets -- no test changes state in either direction. The same comparison over the session-adjacent files alone gives 156 before and 156 after.

Five tests were added to tests/test_session_project_grouping.py, extending the existing file rather than adding a new one. Two of them fail without the fix and pass with it:

  • test_the_gateway_opens_a_session_filed_under_a_project_slug -- the reported bug;
  • test_the_gateway_appends_to_the_transcript_it_opened -- the split-file half;
  • test_two_channels_sharing_a_chat_id_keep_their_own_transcripts -- the guard against over-correcting, and the case that caught the first attempt at this fix;
  • test_a_keyless_transcript_is_still_adopted_across_groups -- pre-key transcripts stay reachable;
  • test_the_gateway_still_files_its_own_sessions_by_channel -- reaching across groups is a fallback for an existing transcript, not a change of where this process writes.

The first attempt, which ungated the search without the key check, broke test_cross_channel_ambiguous_same_id_two_channels in tests/test_cli_session_commands.py. That failure is what produced the key check.

Fix verified against the real workspace that produced the report: the affected session now opens with both of its messages, resolving to its actual file under the launch directory's slug.

  • Relevant tests pass locally
  • Relevant lint / type checks pass locally
  • User-facing docs or screenshots are updated when needed

No docs change: this corrects the behaviour the surrounding docstrings already described, and coins no domain term.

Risk

User-visible change: a surface can now open a transcript filed under another group. On the gateway this closes a gap rather than widening reach -- list_sessions already scans every group, so those sessions were already listed and offered; only opening them was broken. For a slug-grouped surface the search is unchanged apart from the new key check, which makes it strictly stricter: a candidate that would previously have been adopted on its stem alone is now refused when its stored key names a different session.

Transcripts written before the metadata key field are unaffected -- they answer no key and are still adopted on the stem.

session_path now reads the first line of each candidate when the direct path is absent. The read is one readline on a file the caller is deciding whether to open, and the candidate set for an exact chat_id is normally empty or a single file.

Rollback is a revert of the single commit; the change is confined to one function and one new module-private helper, and writes nothing to disk that a revert would strand.

  • Security impact considered
  • Backward compatibility considered
  • Rollback path is clear for risky changes

Related Issues

N/A

… group

The cross-group search in session_path was gated on project_slug, so only a
surface that groups by launch directory ran it. The gateway groups by channel
and never did -- yet list_sessions scans every group, so the served page
offered a conversation started in a terminal and then could not open it:
session.resume missed the file, fell through to its fresh-mint branch, and the
reader got an empty transcript under a new id.

The write verbs resolve through the same function, so the miss was not only a
failed read. A rename or a turn from the page filed a second transcript under
the channel group and split one conversation across two files.

Ungating the search alone is wrong: the glob matches on the chat_id, which
names no channel, so a tui:<id> save adopted the cli:<id> file. A candidate is
now taken only once its own metadata key says it is this session; a keyless
transcript predates that field and is still adopted on the stem.

Co-authored-by: Claude (claude-opus-5[1m]) <noreply@anthropic.com>
@LivXue
LivXue requested review from 0xKT and gloryfromca September 16, 2026 17:40

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blockers; this can merge as far as I am concerned.

Reviewed github/main...HEAD and found the fallback now works for the gateway while the stored-key check prevents an ordinary same-chat-id collision from crossing channels. The direct-path behavior and new-session grouping remain unchanged, and keyless transcript adoption preserves the intended legacy fallback.

Coverage included the repository rules in AGENTS.md/CLAUDE.md and the canonical Session terms in CONTEXT-MAP.md/CONTEXT.md; the complete diff; every session_path caller and the RPC read/write paths; storage-format and grouping history; backward compatibility; the additive tests (no weakened coverage); and the Runtime/TUI architecture boundary.

Verification: uv run pytest tests/test_session_project_grouping.py tests/test_session_manager.py tests/test_cli_session_commands.py tests/test_rpc_session.py -q passed (288 tests). Ruff check, Ruff format check, and ty check raven/session/manager.py also passed. The repository-wide type check currently reports one diagnostic at untouched raven/agent/tools/web.py:516; that is pre-existing and outside this diff.

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blockers; this can merge as far as I am concerned.

This revision only merges current main; the effective PR diff is unchanged, and intervening main commits did not touch the session manager, its RPC callers, or the focused session tests. I rechecked the prior review areas against the merge: repository rules and canonical terms, the full diff, callers/history, backward compatibility, additive test coverage, and architecture boundaries. No earlier conclusion is invalidated.

Verification on this head: the focused session/project-grouping/CLI/RPC suite passed (288 tests), and Ruff check, Ruff format check, and the session-manager type check all passed.

The diff-coverage gate reported 73.68% against a 90% threshold. All five
uncovered lines were _stored_key's error paths, which its docstring already
promises: a file whose head is missing, unreadable, or keyless answers None.

Adds three tests for those three ways to answer None -- a dangling symlink
for the OSError path, a truncated first line for the JSONDecodeError path,
and a head that parses but is not a metadata record. Each asserts the
transcript is still adopted across groups rather than shadowed by a fresh
empty one.

Diff coverage is 100.00% (19/19) with this change.

Co-authored-by: Claude (claude-opus-5[1m]) <noreply@anthropic.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blockers; this can merge as far as I am concerned.

The new revision adds only three additive tests for the documented _stored_key fallback branches; production code and callers are unchanged, and no existing assertion was weakened. I rechecked the full effective diff against the repository rules and canonical terms, caller/history and compatibility assumptions, and the existing Runtime architecture boundary. The new fixtures match the helper's current contract and do not invalidate the prior review.

Verification on this head: the focused session/project-grouping/CLI/RPC suite passed (291 tests), and Ruff check, Ruff format check, and the session-manager type check all passed.

@0xKT

0xKT commented Sep 17, 2026

Copy link
Copy Markdown
Member

**Not a blocker -- I found nothing wrong with the fix, and the mutation pass killed seven of ten. One survivor is worth a line: the sorted() this PR adds is the whole tie-break, and nothing pins it.

raven/session/manager.py:357:

for existing in sorted(self.sessions_dir.glob(f"*/{chat_id}.jsonl")):
    stored = _stored_key(existing)
    if stored is None or stored == key:
        return existing

Measured on the merged tree, one mutation at a time, tree restored (git status empty afterwards):

tests/test_session_project_grouping.py + tests/test_session_manager.py
baseline 125 passed
control -- if False: (never search) killed
reinstate the base guard if self.project_slug and not path.exists(): killed
drop the stored-key check killed
require an exact key (drop the keyless escape hatch) killed
drop sorted() 125 passed -- survives

The control is what makes the survivor mean something: the suite does reach this block.

And the survivor is not equivalent. Six group directories holding the same chat_id, on this filesystem:

raw glob order : ['mmm', '-srv-alpha', '-aaa', '-srv-beta', 'cli', 'zzz']
sorted         : ['-aaa', '-srv-alpha', '-srv-beta', 'cli', 'mmm', 'zzz']
different winner: True

The order only decides anything when more than one candidate passes the filter -- and this PR's own keyless escape hatch (stored is None -> adopt) is what creates that case: two transcripts whose head cannot be read, filed under two groups, both match, and the one returned is whichever comes first. With sorted() that is stable everywhere; without it, it is whatever the filesystem enumerates, which differs between machines and after a directory rewrite. Removing the call would ship green.

A test that fixes the answer -- three keyless transcripts under groups that sort differently from creation order, asserting which path comes back -- closes it in one function. The code as written is correct; this is about keeping it that way.

Two more survivors I am NOT filing, and why

  • the data.get("_type") != "metadata" guard. Dropping it survives, but I could not build a head line where it matters: no record raven writes to a session jsonl carries a key field except the metadata record. Unproven non-equivalence is not a finding. Worth knowing that the test whose docstring claims to cover it (test_a_transcript_whose_head_is_not_metadata_is_still_adopted, head {"role":"user","content":"remember me"}) cannot distinguish "rejected as a whole" from "the field is simply absent".
  • accepting an empty "key": "". Non-equivalent in principle -- empty would refuse instead of adopt -- but Session.key == "" is not reachable from any entrance I could find.

What held up

  • The fix converges rather than forks. The reader no longer guesses the group; it globs the chat id across groups and confirms with the key the transcript itself stored, so the writer's key stays the single definition and _stored_key is its one reader.
  • All three seated surfaces reach it through the same door -- that is the point of the change, and it is what makes the title's "every surface" true rather than a claim.
  • Seat unchanged: raven.session stays in pyproject.toml's inner source_modules and in the kernel's forbidden list; the diff adds no import at all (_stored_key uses json, open, Path, already imported). lint-imports -> Contracts: 10 kept, 0 broken.
  • 431 passed across the session-area suite plus the four structural guards. The five test_updates_install_guard.py failures are PackageNotFoundError: No package metadata was found for raven and were proven identical at base 4b390228, so they are this venv's missing dist-info, not this PR.

One correction to my own brief, which the pass caught: I called tests/test_session_project_grouping.py a new file. It is not -- git cat-file -e 4b390228:tests/test_session_project_grouping.py shows it exists at base; this PR adds 144 lines to an existing 141-line file and deletes nothing.

@0xKT
0xKT merged commit c91f76b into main Sep 17, 2026
18 checks passed
@0xKT
0xKT deleted the fix/gateway_session_group_lookup branch September 17, 2026 11:27
LivXue added a commit that referenced this pull request Oct 12, 2026
… group (#455)

## Summary

A conversation started in the TUI was listed by the served page and then
could not be opened: clicking it produced an empty transcript.

`SessionManager.session_path` looks for a transcript in this process's
own group, and falls back to searching the other groups when it is not
there. That fallback was gated on `project_slug`, so only a surface that
groups by launch directory ever ran it. The gateway groups by channel
and never did -- yet `list_sessions` scans every group, so the page
offered a conversation filed under a launch directory's slug and then
looked for it under `sessions/tui/`. `session.resume` missed the file,
fell through to its fresh-mint branch, and handed the reader an empty
transcript under a new id.

The write verbs resolve through the same function, so the miss was not
only a failed read. A rename, a pin or a turn from the page filed a
second transcript under the channel group, splitting one conversation
across two files:

```
sessions/-srv-alpha/abc.jsonl -> 2 messages
sessions/tui/abc.jsonl        -> 1 messages   (written by the gateway)
```

Ungating the search is necessary but not sufficient, and the second half
is the part worth reviewing. The glob matches on the chat_id, and a
chat_id names no channel: `cli:<id>` and `tui:<id>` are two
conversations, so an ungated search let a `tui:` save adopt the `cli:`
file and append to it. A candidate is now taken only once its own
metadata key says it is this session. A transcript carrying no key
predates that field and is still adopted on the stem alone, which is the
case the search was originally added for.

Matching on the stored key rather than on the directory name is also
what makes this correct on Windows. `key_from_path` tells a slug from a
channel by a leading `-`, which holds only for POSIX paths; a Windows
launch directory slugs to `C--Windows-system32`, with no leading
separator to read.

Root cause was confirmed against a control rather than by inspection:
two `tui:` sessions, same handler, same manager, differing only in which
group holds the file. The one under `sessions/tui/` resumed with its 26
messages; the one under a slug minted a fresh empty session.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

```
uv run pytest tests/test_session_project_grouping.py -q      2 failed, 32 passed
uv run pytest tests/test_cli_session_commands.py -q          43 passed
uv run ruff check raven/session/manager.py tests/test_session_project_grouping.py
                                                             All checks passed!
uv run ruff format --check <same two files>                  2 files already formatted
uv run ty check raven/session/manager.py                     All checks passed!
```

The 2 failures in `test_session_project_grouping.py` are present on
unmodified `main` in this Windows checkout and are not touched by this
change: `test_project_dir_is_not_rewritten_by_a_later_run_elsewhere` and
`test_slug_collides_for_a_separator_and_an_underscore` both build
fixtures from POSIX paths such as `/srv/alpha`, which `Path` renders as
`\srv\alpha` on Windows.

Regression scope was measured rather than asserted. Running `pytest
tests/ -k "session or transcript or workdir or resume"` (about 1085
tests) with and without the change gives **372 failures before and 372
after, with identical failure sets** -- no test changes state in either
direction. The same comparison over the session-adjacent files alone
gives 156 before and 156 after.

Five tests were added to `tests/test_session_project_grouping.py`,
extending the existing file rather than adding a new one. Two of them
fail without the fix and pass with it:

- `test_the_gateway_opens_a_session_filed_under_a_project_slug` -- the
reported bug;
- `test_the_gateway_appends_to_the_transcript_it_opened` -- the
split-file half;
- `test_two_channels_sharing_a_chat_id_keep_their_own_transcripts` --
the guard against over-correcting, and the case that caught the first
attempt at this fix;
- `test_a_keyless_transcript_is_still_adopted_across_groups` -- pre-key
transcripts stay reachable;
- `test_the_gateway_still_files_its_own_sessions_by_channel` -- reaching
across groups is a fallback for an existing transcript, not a change of
where this process writes.

The first attempt, which ungated the search without the key check, broke
`test_cross_channel_ambiguous_same_id_two_channels` in
`tests/test_cli_session_commands.py`. That failure is what produced the
key check.

Fix verified against the real workspace that produced the report: the
affected session now opens with both of its messages, resolving to its
actual file under the launch directory's slug.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

No docs change: this corrects the behaviour the surrounding docstrings
already described, and coins no domain term.

## Risk

User-visible change: a surface can now open a transcript filed under
another group. On the gateway this closes a gap rather than widening
reach -- `list_sessions` already scans every group, so those sessions
were already listed and offered; only opening them was broken. For a
slug-grouped surface the search is unchanged apart from the new key
check, which makes it strictly stricter: a candidate that would
previously have been adopted on its stem alone is now refused when its
stored key names a different session.

Transcripts written before the metadata `key` field are unaffected --
they answer no key and are still adopted on the stem.

`session_path` now reads the first line of each candidate when the
direct path is absent. The read is one `readline` on a file the caller
is deciding whether to open, and the candidate set for an exact chat_id
is normally empty or a single file.

Rollback is a revert of the single commit; the change is confined to one
function and one new module-private helper, and writes nothing to disk
that a revert would strand.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

---------

Co-authored-by: Claude (claude-opus-5[1m]) <noreply@anthropic.com>
Co-authored-by: KT <74288668+0xKT@users.noreply.github.com>
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.

3 participants