feat: persistent session attention tracking across server restarts#334
Conversation
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 6 issue(s) (1 critical) (3 warning).
packages/protocol/src/event-store.ts
The getAttentionSessions query filters is_active = 1 but properly-completed sessions have is_active = 0, meaning persistent attention only works for crash-orphaned sessions, not normal operation. The new features lack test coverage despite the project's TDD requirement.
- 🔴 bugs (L157):
getAttentionSessionsfiltersWHERE is_active = 1, butmarkSessionInactive()inquery-loop.ts:1348setsis_active = 0when the query loop finishes. This means properly-completed sessions where the assistant spoke last will haveis_active = 0and won't appear in attention results. The query only catches sessions orphaned by server crashes (wheremarkSessionInactivenever ran). For normal operation, after a session ends and the user hasn't replied, the session is invisible in the persistent attention feed. Consider usingis_active = 0or removing theis_activefilter entirely (hidden sessions are already excluded).[fixable] - 🟡 missing_tests (L508): No tests for
updateLastSpeaker()orgetAttentionSessions(). These are new public methods on EventStore with specific query semantics (speaker type validation, ordering, limit, active/hidden filters) that should be verified. Existingevent-store.test.tshas patterns for testing similar methods (markSessionInactive,recordUsage).[fixable] - 🔵 style (L225):
migrateCloseTrackingandmigrateAttentionTrackingeach callPRAGMA table_info('sessions')independently despite running back-to-back. The existing migrations (migratePromptTracking,migrateUsageTracking,migrateWorktreeTracking) follow the same pattern, so this is consistent with the codebase — but the redundant PRAGMA calls could be consolidated if more columns are expected.[fixable]
server/__tests__/session-overview.test.ts
The getAttentionSessions query filters is_active = 1 but properly-completed sessions have is_active = 0, meaning persistent attention only works for crash-orphaned sessions, not normal operation. The new features lack test coverage despite the project's TDD requirement.
- 🟡 missing_tests: No tests added for the new features despite the project's TDD requirement. Missing coverage for:
persistentToActivity()(persistent session → activity conversion),checkUncommittedCached()(TTL caching of git status),idleMinutescalculation inderiveActivity(),uncommittedWorkflag propagation, and the merge logic incompute()that deduplicates live vs persistent sessions. Only mocks were added to prevent existing tests from breaking.[fixable]
server/session-overview.ts
The getAttentionSessions query filters is_active = 1 but properly-completed sessions have is_active = 0, meaning persistent attention only works for crash-orphaned sessions, not normal operation. The new features lack test coverage despite the project's TDD requirement.
- 🟡 unsafe_assumptions (L247):
eventStore.getSession(sessionId)is a synchronous SQLite query called for every live session on every broadcast cycle (every 200ms coalesce window). This is uncached, unlike thehasUncommittedWorkcalls which have a 5-minute TTL cache. For a small number of sessions this is fine, but it adds per-broadcast latency that scales with session count. Consider cachinglastSpeakerAtalongsidelastEventTimesor batching the lookups.[fixable] - 🔵 unsafe_assumptions (L303):
hasUncommittedWorkusesexecFileSyncwhich blocks the Node.js event loop. The 5-minute TTL cache mitigates this, but every cache miss (first check percwd, plus periodic refreshes) will block. For persistent sessions with stale worktree paths that no longer exist, the git command fails synchronously before the catch handles it. Consider an async alternative or longer TTL for persistent (non-live) sessions.[fixable]
| WHERE session_id = ?`, | ||
| ), | ||
| getAttentionSessions: db.prepare( | ||
| `SELECT * FROM sessions |
There was a problem hiding this comment.
🔴 bugs: getAttentionSessions filters WHERE is_active = 1, but markSessionInactive() in query-loop.ts:1348 sets is_active = 0 when the query loop finishes. This means properly-completed sessions where the assistant spoke last will have is_active = 0 and won't appear in attention results. The query only catches sessions orphaned by server crashes (where markSessionInactive never ran). For normal operation, after a session ends and the user hasn't replied, the session is invisible in the persistent attention feed. Consider using is_active = 0 or removing the is_active filter entirely (hidden sessions are already excluded). [fixable]
| ); | ||
| } | ||
|
|
||
| updateLastSpeaker(sessionId: string, speaker: 'user' | 'assistant'): void { |
There was a problem hiding this comment.
🟡 missing_tests: No tests for updateLastSpeaker() or getAttentionSessions(). These are new public methods on EventStore with specific query semantics (speaker type validation, ordering, limit, active/hidden filters) that should be verified. Existing event-store.test.ts has patterns for testing similar methods (markSessionInactive, recordUsage). [fixable]
| } | ||
| } | ||
|
|
||
| private migrateCloseTracking(db: Database.Database): void { |
There was a problem hiding this comment.
🔵 style: migrateCloseTracking and migrateAttentionTracking each call PRAGMA table_info('sessions') independently despite running back-to-back. The existing migrations (migratePromptTracking, migrateUsageTracking, migrateWorktreeTracking) follow the same pattern, so this is consistent with the codebase — but the redundant PRAGMA calls could be consolidated if more columns are expected. [fixable]
| const uncommittedWork = session.cwd ? this.checkUncommittedCached(session.cwd, now) : false; | ||
|
|
||
| // Idle minutes from last speaker event | ||
| const meta = this.deps.eventStore.getSession(sessionId); |
There was a problem hiding this comment.
🟡 unsafe_assumptions: eventStore.getSession(sessionId) is a synchronous SQLite query called for every live session on every broadcast cycle (every 200ms coalesce window). This is uncached, unlike the hasUncommittedWork calls which have a 5-minute TTL cache. For a small number of sessions this is fine, but it adds per-broadcast latency that scales with session count. Consider caching lastSpeakerAt alongside lastEventTimes or batching the lookups. [fixable]
| return cached.dirty; | ||
| } | ||
| try { | ||
| const dirty = hasUncommittedWork(cwd) !== null; |
There was a problem hiding this comment.
🔵 unsafe_assumptions: hasUncommittedWork uses execFileSync which blocks the Node.js event loop. The 5-minute TTL cache mitigates this, but every cache miss (first check per cwd, plus periodic refreshes) will block. For persistent sessions with stale worktree paths that no longer exist, the git command fails synchronously before the catch handles it. Consider an async alternative or longer TTL for persistent (non-live) sessions. [fixable]
- Remove is_active=1 filter from getAttentionSessions query — properly completed sessions (is_active=0) now appear in persistent attention feed - Cache lastSpeakerAt per session to avoid per-broadcast SQLite lookups - Use longer TTL (15min) for uncommitted work checks on persistent sessions - Add EventStore tests for updateLastSpeaker and getAttentionSessions - Add session-overview tests for persistent merging, uncommitted work, idle minutes, and lastSpeakerAt caching Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 6 issue(s) (3 warning).
server/session-overview.ts
Solid feature with good persistence layer tests and caching strategy. Main concerns: execFileSync on the broadcast hot path can block the event loop (up to 30s per cache miss), closedBy is dead code, and the new sessionsToAttention frontend logic lacks test coverage.
- 🟡 unsafe_assumptions (L307):
hasUncommittedWork()usesexecFileSyncwith a 30-second timeout (WORKTREE_GIT_TIMEOUT_MS), and is called synchronously insidecompute()which runs on the main event loop during everybroadcast()andgetSnapshot(). When the TTL cache expires, this blocks the Node.js event loop for potentially up to 30s per worktree path (multiplied by number of sessions). Consider using an async check or a shorter dedicated timeout for the broadcast path.[fixable] - 🔵 bugs (L251):
idleMinutescan be negative ifspeakerAtis slightly in the future (e.g., due toDate.now()being called beforeupdateLastSpeakerAtwrites a value that uses a laterDate.now()).Math.round((now - speakerAt) / 60_000)would yield a small negative number. The frontend guards witha.idleMinutes > 0before displaying, so no visible bug, butMath.max(0, ...)would be more defensive.[fixable]
server/chat.ts
Solid feature with good persistence layer tests and caching strategy. Main concerns: execFileSync on the broadcast hot path can block the event loop (up to 30s per cache miss), closedBy is dead code, and the new sessionsToAttention frontend logic lacks test coverage.
- 🟡 regressions (L852):
updateLastSpeaker('user')is called for resumed sessions (line 852),sendToChat(line 973), andinterruptChat(line 1007), but NOT for the initial user prompt of new sessions in_startChatInner. After the assistant's first turn,turn_endsetslast_speaker='assistant', which is correct. However, the asymmetry meansoverviewEmitter.updateLastSpeakerAt()is also never called for new session user messages, soidleMinutesfor the first turn of new sessions will fall back tolastEventAtrather than reflecting the user's message time.[fixable] - 🔵 missing_tests (L852): The three
updateLastSpeaker('user')calls inchat.ts(resume path, sendToChat, interruptChat) have no direct test coverage. The event-store tests cover the method itself, but no integration test verifies that user messages actually trigger the call.[fixable]
packages/protocol/src/event-store.ts
Solid feature with good persistence layer tests and caching strategy. Main concerns: execFileSync on the broadcast hot path can block the event loop (up to 30s per cache miss), closedBy is dead code, and the new sessionsToAttention frontend logic lacks test coverage.
- 🔵 style (L221):
closedBycolumn andSessionClosedBytype are added to the schema, migration, insert, update, androwToSessionmapping, but no code anywhere in the codebase actually setsclosedByto a non-null value. This is dead code that adds migration and schema overhead. Consider deferring this column to a PR that actually uses it, or at minimum note in the PR description that this is intentional scaffolding.[fixable]
frontend/src/hooks/useAttentionFeed.ts
Solid feature with good persistence layer tests and caching strategy. Main concerns: execFileSync on the broadcast hot path can block the event loop (up to 30s per cache miss), closedBy is dead code, and the new sessionsToAttention frontend logic lacks test coverage.
- 🟡 missing_tests (L135): The
sessionsToAttentionfunction was substantially rewritten (from a simple.filter().map()to a loop with three branches: waiting, done+uncommittedWork, done+awaitingReply), but the existinguseAttentionFeed.test.tshas zero tests for session-derived attention items. The idle label formatting logic (minutes vs hours) and the uncommittedWork branching are untested.[fixable]
| return cached.dirty; | ||
| } | ||
| try { | ||
| const dirty = hasUncommittedWork(cwd) !== null; |
There was a problem hiding this comment.
🟡 unsafe_assumptions: hasUncommittedWork() uses execFileSync with a 30-second timeout (WORKTREE_GIT_TIMEOUT_MS), and is called synchronously inside compute() which runs on the main event loop during every broadcast() and getSnapshot(). When the TTL cache expires, this blocks the Node.js event loop for potentially up to 30s per worktree path (multiplied by number of sessions). Consider using an async check or a shorter dedicated timeout for the broadcast path. [fixable]
|
|
||
| // Idle minutes from last speaker event (cached to avoid per-broadcast SQLite) | ||
| const speakerAt = this.getLastSpeakerAt(sessionId) ?? lastEventAt; | ||
| const idleMinutes = Math.round((now - speakerAt) / 60_000); |
There was a problem hiding this comment.
🔵 bugs: idleMinutes can be negative if speakerAt is slightly in the future (e.g., due to Date.now() being called before updateLastSpeakerAt writes a value that uses a later Date.now()). Math.round((now - speakerAt) / 60_000) would yield a small negative number. The frontend guards with a.idleMinutes > 0 before displaying, so no visible bug, but Math.max(0, ...) would be more defensive. [fixable]
| messageId, | ||
| text: fullPrompt, | ||
| }); | ||
| eventStore.updateLastSpeaker(options.resume, 'user'); |
There was a problem hiding this comment.
🟡 regressions: updateLastSpeaker('user') is called for resumed sessions (line 852), sendToChat (line 973), and interruptChat (line 1007), but NOT for the initial user prompt of new sessions in _startChatInner. After the assistant's first turn, turn_end sets last_speaker='assistant', which is correct. However, the asymmetry means overviewEmitter.updateLastSpeakerAt() is also never called for new session user messages, so idleMinutes for the first turn of new sessions will fall back to lastEventAt rather than reflecting the user's message time. [fixable]
| messageId, | ||
| text: fullPrompt, | ||
| }); | ||
| eventStore.updateLastSpeaker(options.resume, 'user'); |
There was a problem hiding this comment.
🔵 missing_tests: The three updateLastSpeaker('user') calls in chat.ts (resume path, sendToChat, interruptChat) have no direct test coverage. The event-store tests cover the method itself, but no integration test verifies that user messages actually trigger the call. [fixable]
| if (!columnNames.has('wt_id')) { | ||
| db.exec('ALTER TABLE sessions ADD COLUMN wt_id TEXT'); | ||
| this.log.info('migrated sessions table: added wt_id'); | ||
| } |
There was a problem hiding this comment.
🔵 style: closedBy column and SessionClosedBy type are added to the schema, migration, insert, update, and rowToSession mapping, but no code anywhere in the codebase actually sets closedBy to a non-null value. This is dead code that adds migration and schema overhead. Consider deferring this column to a PR that actually uses it, or at minimum note in the PR description that this is intentional scaffolding. [fixable]
| navigateTo: `/chat/${a.sessionId}`, | ||
| updatedAt: a.lastEventAt || 0, | ||
| }); | ||
| } else if (a.state === 'done') { |
There was a problem hiding this comment.
🟡 missing_tests: The sessionsToAttention function was substantially rewritten (from a simple .filter().map() to a loop with three branches: waiting, done+uncommittedWork, done+awaitingReply), but the existing useAttentionFeed.test.ts has zero tests for session-derived attention items. The idle label formatting logic (minutes vs hours) and the uncommittedWork branching are untested. [fixable]
Add last_speaker tracking to SessionMeta to enable persistent attention feed items for sessions awaiting user reply, sessions with uncommitted work, and sessions waiting for permissions/review. **Changes:** - Add lastSpeaker/lastSpeakerAt/closedBy to SessionMeta - Add uncommittedWork/idleMinutes to SessionActivity - Add EventStore.updateLastSpeaker() to record speaker on each turn - Add EventStore.getAttentionSessions() to query sessions awaiting reply - Add migration for last_speaker and last_speaker_at columns Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…heck **Server changes:** - Export hasUncommittedWork() from worktree.ts for attention tracking - Merge persistent attention sessions into session-overview compute() - Add persistentToActivity() to convert EventStore sessions to activities - Add uncommitted work cache (5-min TTL) to avoid expensive git calls - Add test mock for eventStore and hasUncommittedWork **Frontend changes:** - Update useAttentionFeed.ts sessionsToAttention(): - Tier 1: done + uncommittedWork (red, "uncommitted work" + idle) - Tier 1: done without uncommittedWork (amber, "awaiting reply") - Show idle time when available (e.g., "2h ago") Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Remove is_active=1 filter from getAttentionSessions query — properly completed sessions (is_active=0) now appear in persistent attention feed - Cache lastSpeakerAt per session to avoid per-broadcast SQLite lookups - Use longer TTL (15min) for uncommitted work checks on persistent sessions - Add EventStore tests for updateLastSpeaker and getAttentionSessions - Add session-overview tests for persistent merging, uncommitted work, idle minutes, and lastSpeakerAt caching Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fed7f55 to
0996cdf
Compare
Remove duplicate SessionClosedBy type, duplicate updateLastSpeaker/ getAttentionSessions methods, and dead updateLastSpeakerAt call that caused CI type-check failures on PR #334. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add updateLastSpeaker('user') for new sessions in query-loop
- Guard idleMinutes with Math.max(0, ...) in both derive paths
- Add sessionsToAttention tests (awaiting, waiting, uncommitted, done)
- Note closedBy column as intentional scaffolding
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove is_active=1 filter from getAttentionSessions query — properly completed sessions (is_active=0) now appear in persistent attention feed - Cache lastSpeakerAt per session to avoid per-broadcast SQLite lookups - Use longer TTL (15min) for uncommitted work checks on persistent sessions - Add EventStore tests for updateLastSpeaker and getAttentionSessions - Add session-overview tests for persistent merging, uncommitted work, idle minutes, and lastSpeakerAt caching Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove duplicate SessionClosedBy type, duplicate updateLastSpeaker/ getAttentionSessions methods, and dead updateLastSpeakerAt call that caused CI type-check failures on PR #334. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(protocol): add persistent session attention tracking Add last_speaker tracking to SessionMeta to enable persistent attention feed items for sessions awaiting user reply, sessions with uncommitted work, and sessions waiting for permissions/review. **Changes:** - Add lastSpeaker/lastSpeakerAt/closedBy to SessionMeta - Add uncommittedWork/idleMinutes to SessionActivity - Add EventStore.updateLastSpeaker() to record speaker on each turn - Add EventStore.getAttentionSessions() to query sessions awaiting reply - Add migration for last_speaker and last_speaker_at columns Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(server): address Centaur review findings on PR #334 - Remove is_active=1 filter from getAttentionSessions query — properly completed sessions (is_active=0) now appear in persistent attention feed - Cache lastSpeakerAt per session to avoid per-broadcast SQLite lookups - Use longer TTL (15min) for uncommitted work checks on persistent sessions - Add EventStore tests for updateLastSpeaker and getAttentionSessions - Add session-overview tests for persistent merging, uncommitted work, idle minutes, and lastSpeakerAt caching Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(protocol): remove duplicate declarations from rebase merge Remove duplicate SessionClosedBy type, duplicate updateLastSpeaker/ getAttentionSessions methods, and dead updateLastSpeakerAt call that caused CI type-check failures on PR #334. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(server): cache speaker lookups to avoid per-broadcast SQLite queries Add speakerCache in SessionOverviewEmitter — lazily populated from EventStore on first access, updated via updateSpeaker() on turn_end. Eliminates synchronous eventStore.getSession() call on every 200ms broadcast cycle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs(design): session state machine design doc Comprehensive design for fixing session lifecycle bugs via explicit state machine. Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(server): address Centaur review findings on PR #340 - Fire user_message callback to keep speaker cache in sync with DB - Clean speakerCache on forget() to prevent memory leak - Merge duplicate vi.mock calls in test file - Add stale-cache and forget-cleanup tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: format chat.ts with prettier Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(server): address 2nd Centaur review — sessionId lifecycle + type safety - Pass sessionId through callback instead of looking up from registry (registry already cleared when 'end' fires — was a memory leak) - Narrow speakerCache type to 'user' | 'assistant' | null Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs(design): address review findings on state machine design Fix 6 issues and 2 minor gaps identified during review: - Remove contradictory SUSPENDED → DETACHED transition - Fix recovery-then-fallthrough routing gap (explicit resume after force-end) - Scope force flag to crash recovery paths only - Add interrupt behavior to transition table - Force-end all non-ENDED sessions on startup (registry is empty post-restart) - Add duplicate startChat guard for CREATED/STARTING states - Clarify OTLP metrics vs Jaeger tracing - Note existing sessions table schema in migration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Summary
Adds persistent session attention tracking to the "What's Next" feed to show sessions awaiting user reply, sessions with uncommitted work, and sessions waiting for input even after server restarts.
Changes
Protocol (
packages/protocol)lastSpeaker/lastSpeakerAt/closedBytoSessionMetauncommittedWork/idleMinutestoSessionActivityEventStore.updateLastSpeaker()to record speaker on each turnEventStore.getAttentionSessions()to query sessions awaiting replylast_speakerandlast_speaker_atcolumnsServer (
server/)updateLastSpeaker('user')on all user_message events (resume, send, interrupt)updateLastSpeaker('assistant')on turn_end eventshasUncommittedWork()for attention trackingeventStoreand mockhasUncommittedWorkFrontend (
frontend/src/hooks)sessionsToAttention()to handle new fields:Test Plan
npx tsc -b packages/protocolnpx tsc -p tsconfig.build.jsonnpm test -- server/__tests__/session-overview packages/protocol/__tests__/event-store🤖 Generated with Claude Code