fix(review): dedupe resumed-session rows + newest-wins lifecycle name/mode (PR #139)

- Duplicate rows: transcript-history rows are keyed by the Claude
  conversation UUID (.jsonl filename stem), which diverges from the Codeman
  session id for resumed (claudeSessionId = resumeSessionId != id) and
  /clear-respawned sessions, so one conversation surfaced as both a live row
  and a history-only row. mergeUnifiedSessions now builds an alias map
  (claudeSessionId -> Codeman id) from the live + persisted views and
  resolves history/lifecycle keys through it; the route feeds
  SessionState.resumeSessionId as the persisted alias.
- Inverted precedence: SessionLifecycleLog.query() returns entries
  NEWEST-first, but the merge loop unconditionally overwrote name/mode so
  the OLDEST entry in the window won (stale rename/mode). First-seen now
  wins, mirroring the existing lastActivityAt guard.
- Tests: resumed session yields ONE row (service unit + route end-to-end
  with a real transcript fixture); renamed-then-deleted session surfaces
  the NEWEST name/mode. All 4 new tests fail against the pre-fix code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-12 12:41:48 +02:00
parent ce4c5dd584
commit 0ad673794f
4 changed files with 119 additions and 11 deletions
+4 -1
View File
@@ -1784,7 +1784,9 @@ export function registerSessionRoutes(
};
});
// Persisted sessions (state.json).
// Persisted sessions (state.json). resumeSessionId is the Claude
// conversation UUID a resumed session continues — feed it to the merge's
// alias map so its transcript row folds into this session.
const persisted: PersistedSessionInput[] = Object.values(ctx.store.getState().sessions).map((p) => ({
id: p.id,
name: p.name,
@@ -1793,6 +1795,7 @@ export function registerSessionRoutes(
workingDir: p.workingDir,
createdAt: p.createdAt,
lastActivityAt: p.lastActivityAt,
claudeSessionId: p.resumeSessionId,
}));
// Lifecycle audit log (newest-first, capped).