Skip to content

fix(serve): own session titles in the store instead of a Serve-local cache (#10533) - #10539

Open
nilbot wants to merge 2 commits into
esengine:main-v2from
nilbot:fix/serve-session-title-ownership
Open

nilbot wants to merge 2 commits into
esengine:main-v2from
nilbot:fix/serve-session-title-ownership

Conversation

@nilbot

@nilbot nilbot commented Sep 19, 2026

Copy link
Copy Markdown

Summary

reasonix serve shows sessions-v4 sessions in the sidebar as their 32-hex session id.
Fixing it surfaced a second, larger fault on the same line, so this PR covers all three.

Defect 1 - the listing had no display title to show. For a v4 session it copied
info.Title straight from the event-log projection. That field is non-empty only when a
title was explicitly recorded (a manual rename, or the agent's set_session_title tool), so
a session that was never explicitly named had no display title at all.

Defect 2 - the generated-title cache was misplaced. Serve rooted
.session-titles.json at ctrl.SessionDir() - the host's legacy transcript catalog -
while v4 sessions and the titles that describe them live in the sibling sessions-v4
store. In our reproduction the loaded path was .../sessions/.session-titles.json while the
file sat in .../sessions-v4/, so every v4 read saw an empty cache and a title a previous
Serve process generated was never reusable.

Defect 3 - a generated title had two owners. Even with the path fixed, Serve kept
generated titles in a machine-local file while the store kept durable session/title
events. That file is not a cache of the durable title; it is a parallel title store. The
CLI resume picker reads SessionInfo.Title and the preview and never opens it, so the same
session could carry different names depending on which surface asked, and a migration could
drop one of them.

Changes

  • titleCacheDir maps the legacy catalog to the store root through
    session.RootForLegacyDir.
  • sessionDisplayTitle resolves every stored row in one place: explicit event-log title,
    then generated title, then the first authored message. The session id keys the local
    generation cache and the catalog preview stands in for the first message, because a v4
    session has no transcript path.
  • A generated title is written through to the session's own event log with
    SetTitleIfSequence, guarded by the revision the listing read, so a manual rename racing
    the generation wins. The write is bounded (2s) and best-effort: a session owned by another
    runtime keeps its title and the cache serves that poll.
  • v4 rows report the durable log mtime (UpdatedAt) rather than CreatedAt, so
    newest-first ordering is real.

Design

docs/SESSION_TITLE_OWNERSHIP.md records the model rather than leaving it in review
threads: store identity versus display name, the four-rung resolution ladder and what each
rung may assume during the catalog rebuild window, why the cache must stay disposable, and
the migration gap described below.

Two questions I would rather ask than assume:

  1. Generating a title is still a GET side effect. Now that the write is durable, a
    listing poll can write session state. The shape I would argue for is generating on turn
    completion (or explicit rename) so a listing is a pure read - that needs a decision about
    where the work runs. The write-through is bounded in the meantime: once it succeeds the
    durable title exists so it does not repeat, and it is revision-guarded and best-effort
    against another writer's lease.
  2. Title resolution could move into the read model. It lives in this handler today;
    a display_title resolved by the session read model would let every transport render the
    same ladder without re-deriving it. Out of scope here, but it is the natural next step.

Out of scope (found while investigating, belongs elsewhere)

The migration bridge in #10515 imports legacy sessions and deletes the sources, but carries
no title: BranchMeta.CustomTitle and TopicTitle both reach the v4 store empty while
the sidecar holding them is deleted. Verified on the JSONL path; the sessions-v3 path has no
title handling either. migration-map.json records sourcePath to targetId, which is
enough to re-key them. Any fix there must also revisit internal/session/import_resolver.go,
where session/title is grouped with session/config and diagnostic as not meaningful -
a migrated title would otherwise be discarded as noise on re-import.

The legacy .jsonl scan in this handler, and the test guarding it, become dead code once
that migration deletes its sources.

Issues

Fixes #10533

Verification

  • go test ./internal/serve/ ./internal/session/ ./internal/control/ ./internal/cli/ - pass.
  • go run ./tools/repolint - clean (1234 baselined findings);
    golangci-lint run --timeout=5m ./internal/serve/... - 0 issues.
  • Mutation-checked rather than green by construction: reverting the cache root fails
    TestSessionsReturnsStoredV4Title and TestSessionsKeepsLegacyTitlesAfterCacheMove;
    disabling the write-through fails TestGeneratedTitleBecomesDurable. Each guard
    discriminates its fix from its bug.
  • Durable-write cases: a second independent service over the same store reads the generated
    title with the local cache deleted; a rename landing between read and write survives
    (TestGeneratedTitleWriteRespectsConcurrentRename); a session held by another writer still
    lists correctly (TestGeneratedTitleWriteFailureKeepsListing).
  • End-to-end on a local reasonix serve (isolated REASONIX_HOME, mock OpenAI-compatible
    provider): before, /sessions returned only hex ids for v4 rows; after, a stored title is
    returned and an untitled session falls back to its first message. After one poll both
    sessions carry a session/title event, and with .session-titles.json deleted a
    restarted Serve still lists both titles - they now come from the store.

Documentation impact

Documentation-impact: updated - docs/SESSION_TITLE_OWNERSHIP.md documents session title
ownership, the resolution ladder, and the migration gap. GET /sessions keeps its wire shape.

Cache impact

Cache-impact: none - the provider-visible prompt and tool schemas are untouched; the title
generation call is a separate bounded flash request. This change adds one durable
session/title event per session that first generates a title, which forbids prefix reuse
for that session only after the appended event.
Cache-guard: go test ./internal/serve/ -run 'TestTitleCacheDirIsTheStoreRootBesideLegacySessions|TestSessionsReturnsStoredV4Title|TestSessionsKeepsLegacyTitlesAfterCacheMove|TestGeneratedTitleBecomesDurable|TestGeneratedTitleWriteRespectsConcurrentRename'
System-prompt-review: N/A

Problem
`reasonix serve` lists sessions-v4 sessions in the Web UI sidebar by their 32-hex
session id. Two independent defects produce that:

1. The v4 listing copied `info.Title` straight from the event-log projection.
   That field is non-empty only when a title was explicitly recorded (a manual
   rename, or the agent's title tool), so a session that was never explicitly
   named had no display title at all.
2. Serve rooted `.session-titles.json` at `ctrl.SessionDir()` - the host's
   legacy transcript catalog - while v4 sessions and the generated titles that
   describe them live in the sibling `sessions-v4` store. The cache was
   therefore loaded from a path that usually does not exist and was empty every
   run, so a title a previous Serve generated was never reusable.

Both are the same failure class as the open CLI resume reports: a surface still
reading the legacy layout after session writes moved to the v4 store.

Fix
- `titleCacheDir` maps the legacy catalog to the store root through
  `session.RootForLegacyDir`.
- `sessionDisplayTitle` resolves each stored row in one place: explicit
  event-log title, then generated title, then the first authored message. The
  session id keys the local generation cache and the catalog preview stands in
  for the first message, because a v4 session has no transcript path.
- A generated title is written through to the session's own event log with
  `SetTitleIfSequence`, guarded by the revision the listing read, so a manual
  rename racing the generation wins. The write is bounded and best-effort: a
  session owned by another runtime keeps its title and the cache serves that
  poll. This removes the second, non-durable owner a generated title used to
  have, which is why the CLI resume picker and Serve could previously name the
  same session differently.
- v4 rows report the durable log mtime (`UpdatedAt`) rather than `CreatedAt`, so
  newest-first ordering is real.

Verification
- go test ./internal/serve/ ./internal/session/ ./internal/control/ ./internal/cli/
- go run ./tools/repolint; golangci-lint run --timeout=5m ./internal/serve/...
- Mutation-checked: reverting the cache root fails TestSessionsReturnsStoredV4Title
  and TestSessionsKeepsLegacyTitlesAfterCacheMove; disabling the write-through
  fails TestGeneratedTitleBecomesDurable.
- End-to-end on a local serve (isolated REASONIX_HOME, mock provider): after one
  /sessions poll both sessions carry a session/title event, and with
  .session-titles.json deleted a restarted Serve still lists both titles.
Session titles accumulated hidden context that only exists in review threads: a
session has a store identity (the immutable v4 id) and a display name, the two
were the same value before the v4 cutover, and every surface that renders the id
is a surface that has not been taught the difference.

Write the model down so it is not re-derived per fix:

- the four-rung resolution ladder (explicit event-log title, generated title,
  first authored message, store id as last resort) and what each rung may assume
  during the catalog rebuild window;
- why a generated title must have exactly one durable owner, and why the JSON
  cache must stay disposable - deleting it should cost generation requests, never
  information;
- the migration gap: the v4 import bridge deletes legacy sources while
  BranchMeta.CustomTitle and TopicTitle both land empty, and
  import_resolver.go classifies session/title as not meaningful, so any fix there
  must revisit both.
@nilbot

nilbot commented Sep 19, 2026

Copy link
Copy Markdown
Author

Reissued from #10536 so the change set reads as one story instead of a read-path fix that grew
into an ownership fix. The earlier PR is closed with a pointer here; the corrections to my
original diagnosis of #10533 are in that issue's thread rather than carried into this one.

Summary of how the scope grew, since the commits are new: the reported symptom was a missing
display title, which was two defects (nothing resolved a title for v4 rows; the generated
cache sat in the legacy catalog). Investigating those surfaced the reason they were hard to
see - a generated title had two owners with different durability, so Serve and the CLI resume
picker could name the same session differently. The commit here makes the store the single
owner; docs/SESSION_TITLE_OWNERSHIP.md records the model and the two open design questions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Go rewrite (1.x) — main-v2 branch, active development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

serve: GET /sessions 对 sessions-v4 会话不返回 title,Web UI 会话名显示成 sessionId(hex)

1 participant