Skip to content

feat: persistent session attention tracking across server restarts#334

Merged
dimakis merged 5 commits into
mainfrom
feat/whats-next-attention
May 17, 2026
Merged

feat: persistent session attention tracking across server restarts#334
dimakis merged 5 commits into
mainfrom
feat/whats-next-attention

Conversation

@dimakis
Copy link
Copy Markdown
Owner

@dimakis dimakis commented May 16, 2026

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.

  • Sessions awaiting user reply (assistant spoke last) persist across server restarts
  • Sessions with uncommitted work show in attention feed with idle time
  • All session-based attention items are now tier 1 (matching priority requirement)

Changes

Protocol (packages/protocol)

  • 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

Server (server/)

  • chat.ts: Call updateLastSpeaker('user') on all user_message events (resume, send, interrupt)
  • index.ts: Call updateLastSpeaker('assistant') on turn_end events
  • session-overview.ts: Merge persistent attention sessions from EventStore into live activities
  • worktree.ts: Export hasUncommittedWork() for attention tracking
  • Add uncommitted work cache (5-min TTL) to avoid expensive git calls
  • Update test mocks to include eventStore and mock hasUncommittedWork

Frontend (frontend/src/hooks)

  • useAttentionFeed.ts: Update sessionsToAttention() to handle new fields:
    • Tier 1: done + uncommittedWork → red, "uncommitted work" + idle time
    • Tier 1: done without uncommittedWork → amber, "awaiting your reply" + idle time
    • Tier 1: waiting → red, "permission/review needed" (unchanged)

Test Plan

  • Protocol builds: npx tsc -b packages/protocol
  • Server builds: npx tsc -p tsconfig.build.json
  • Tests pass: npm test -- server/__tests__/session-overview packages/protocol/__tests__/event-store
  • Manual verification: start Mitzo server, create session, send message, restart server, verify attention feed shows session

🤖 Generated with Claude Code

Copy link
Copy Markdown
Owner Author

@dimakis dimakis left a comment

Choose a reason for hiding this comment

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

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): 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]
  • 🟡 missing_tests (L508): 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]
  • 🔵 style (L225): 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]

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), idleMinutes calculation in deriveActivity(), uncommittedWork flag propagation, and the merge logic in compute() 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 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]
  • 🔵 unsafe_assumptions (L303): 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]

WHERE session_id = ?`,
),
getAttentionSessions: db.prepare(
`SELECT * FROM sessions
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔴 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]

Comment thread packages/protocol/src/event-store.ts Outdated
);
}

updateLastSpeaker(sessionId: string, speaker: 'user' | 'assistant'): void {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 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 {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 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);
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 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]

Comment thread server/session-overview.ts Outdated
return cached.dirty;
}
try {
const dirty = hasUncommittedWork(cwd) !== null;
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 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]

dimakis added a commit that referenced this pull request May 16, 2026
- 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>
Copy link
Copy Markdown
Owner Author

@dimakis dimakis left a comment

Choose a reason for hiding this comment

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

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() 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]
  • 🔵 bugs (L251): 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]

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), 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]
  • 🔵 missing_tests (L852): 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]

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): 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]

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 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]

Comment thread server/session-overview.ts Outdated
return cached.dirty;
}
try {
const dirty = hasUncommittedWork(cwd) !== null;
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 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]

Comment thread server/session-overview.ts Outdated

// 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);
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 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]

Comment thread server/chat.ts
messageId,
text: fullPrompt,
});
eventStore.updateLastSpeaker(options.resume, 'user');
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 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]

Comment thread server/chat.ts
messageId,
text: fullPrompt,
});
eventStore.updateLastSpeaker(options.resume, 'user');
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 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');
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 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]

Comment thread frontend/src/hooks/useAttentionFeed.ts Outdated
navigateTo: `/chat/${a.sessionId}`,
updatedAt: a.lastEventAt || 0,
});
} else if (a.state === 'done') {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 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]

dimakis and others added 3 commits May 17, 2026 17:56
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>
@dimakis dimakis force-pushed the feat/whats-next-attention branch from fed7f55 to 0996cdf Compare May 17, 2026 16:58
dimakis and others added 2 commits May 17, 2026 18:16
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>
@dimakis dimakis merged commit df8286a into main May 17, 2026
1 check passed
dimakis added a commit that referenced this pull request May 19, 2026
- 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 added a commit that referenced this pull request May 19, 2026
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>
dimakis added a commit that referenced this pull request May 19, 2026
* 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>
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