From bfa498ce959ed3578ab9038d854f7f0b06720e93 Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 16 May 2026 08:03:20 +0100 Subject: [PATCH 1/3] fix(session-list): preserve timestamps on resume and show recent zero-turn sessions Two bugs in session list rendering: 1. **Timestamp reset on resume**: When sessions were resumed (e.g. on server restart), upsertSession() was called without updatedAt, causing the EventStore to default to "now". This made all recently-resumed sessions show identical timestamps. Fix: read existing updatedAt via getSession() and pass it through explicitly. 2. **Missing new sessions**: Zero-turn, zero-prompt, inactive sessions were filtered out, hiding newly created sessions that hadn't completed their first turn yet. Fix: keep sessions visible for 1 hour after creation, then apply the filter. Fixes session list showing all sessions with same "18m ago" timestamp and newly created sessions disappearing before first message completes. Co-Authored-By: Claude Sonnet 4.5 --- server/chat.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/server/chat.ts b/server/chat.ts index 5c11efac..256c0837 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -689,7 +689,9 @@ async function _startChatInner( // Pre-register resumed sessions in EventStore so they're discoverable // even if the query loop dies before the first assistant event. + // Preserve existing updatedAt so server restarts don't reset all timestamps. if (options.resume) { + const existingMeta = eventStore.getSession(options.resume); eventStore.upsertSession({ sessionId: options.resume, cwd, @@ -698,6 +700,7 @@ async function _startChatInner( isActive: true, ...(worktreePath ? { wtId } : {}), ...(options.telosTaskId ? { telosTaskId: options.telosTaskId } : {}), + ...(existingMeta ? { updatedAt: existingMeta.updatedAt } : {}), }); } @@ -1509,10 +1512,12 @@ export function getSessionsCached(offset = 0, limit = SESSION_PAGE_SIZE) { const all = eventStore.listSessions().filter((m) => { // Hide sessions that were never used through Mitzo (e.g. automated // code review sessions discovered from filesystem). Active sessions - // always show regardless of turn count. - // Note: new sessions are created with is_active=1 by default (see EventStore - // schema), so a brand-new session will never be filtered out here. - if (m.numTurns === 0 && m.promptCount === 0 && !m.isActive) return false; + // always show regardless of turn count. Recently created sessions + // (< 1 hour) are kept even with no turns — they may still be starting. + if (m.numTurns === 0 && m.promptCount === 0 && !m.isActive) { + const ONE_HOUR = 60 * 60 * 1000; + return Date.now() - m.createdAt < ONE_HOUR; + } return true; }); const page = all.slice(offset, offset + limit); From 03cddfbd5b5baeb70a64bfb3ba2f49f30b51bb27 Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 16 May 2026 08:35:02 +0100 Subject: [PATCH 2/3] =?UTF-8?q?fix(server):=20address=20PR=20#326=20review?= =?UTF-8?q?=20=E2=80=94=20hoist=20constant=20+=20add=20test=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- server/__tests__/session-cache.test.ts | 102 +++++++++++++++++++++++++ server/chat.ts | 6 +- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/server/__tests__/session-cache.test.ts b/server/__tests__/session-cache.test.ts index b6ecd914..71645853 100644 --- a/server/__tests__/session-cache.test.ts +++ b/server/__tests__/session-cache.test.ts @@ -217,6 +217,51 @@ describe('getSessionsCached', () => { cwd: '/projects/bar', }); }); + + it('keeps a recent zero-turn inactive session within the grace period', async () => { + const thirtyMinutesAgo = Date.now() - 30 * 60 * 1000; + mockListSessionsMeta.mockReturnValue([ + { + sessionId: 'sess-recent-zero', + summary: 'Just created', + numTurns: 0, + promptCount: 0, + isActive: false, + createdAt: thirtyMinutesAgo, + updatedAt: thirtyMinutesAgo, + branch: null, + cwd: null, + }, + ]); + + const { getSessionsCached } = await import('../chat.js'); + const { sessions } = getSessionsCached(); + + expect(sessions).toHaveLength(1); + expect(sessions[0].id).toBe('sess-recent-zero'); + }); + + it('hides an old zero-turn inactive session past the grace period', async () => { + const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; + mockListSessionsMeta.mockReturnValue([ + { + sessionId: 'sess-old-zero', + summary: 'Stale empty session', + numTurns: 0, + promptCount: 0, + isActive: false, + createdAt: twoHoursAgo, + updatedAt: twoHoursAgo, + branch: null, + cwd: null, + }, + ]); + + const { getSessionsCached } = await import('../chat.js'); + const { sessions } = getSessionsCached(); + + expect(sessions).toHaveLength(0); + }); }); describe('syncSessionTimestamps', () => { @@ -347,6 +392,63 @@ describe('syncSessionTimestamps', () => { }); }); +describe('resume timestamp preservation', () => { + it('preserves updatedAt when resuming an existing session', async () => { + const originalUpdatedAt = Date.now() - 3600_000; // 1 hour ago + const existingMeta = { + sessionId: 'sess-resume', + summary: 'Old session', + updatedAt: originalUpdatedAt, + createdAt: originalUpdatedAt - 7200_000, + numTurns: 5, + promptCount: 3, + isActive: false, + branch: 'main', + cwd: '/projects/foo', + manuallyRenamed: false, + }; + mockGetSession.mockReturnValue(existingMeta); + + // Simulate the resume upsert pattern from startSession (chat.ts L688-701): + // const existingMeta = eventStore.getSession(id); + // eventStore.upsertSession({ ..., ...(existingMeta ? { updatedAt: existingMeta.updatedAt } : {}) }); + const sessionId = 'sess-resume'; + const retrieved = mockEventStore.getSession(sessionId); + mockEventStore.upsertSession({ + sessionId, + cwd: '/projects/foo', + mode: 'code', + branch: 'main', + ...(retrieved ? { updatedAt: retrieved.updatedAt } : {}), + }); + + expect(mockUpsertSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'sess-resume', + updatedAt: originalUpdatedAt, + }), + ); + }); + + it('omits updatedAt when resuming a session with no prior metadata', async () => { + mockGetSession.mockReturnValue(null); + + const sessionId = 'sess-new-resume'; + const retrieved = mockEventStore.getSession(sessionId); + mockEventStore.upsertSession({ + sessionId, + cwd: '/projects/foo', + mode: 'code', + branch: 'main', + ...(retrieved ? { updatedAt: retrieved.updatedAt } : {}), + }); + + const call = mockUpsertSession.mock.calls[0][0]; + expect(call.sessionId).toBe('sess-new-resume'); + expect(call).not.toHaveProperty('updatedAt'); + }); +}); + describe('hideSession persistence', () => { it('calls eventStore.hideSession to persist deletion', async () => { const { hideSession } = await import('../chat.js'); diff --git a/server/chat.ts b/server/chat.ts index 256c0837..be78b4d5 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -40,6 +40,9 @@ import { INTERNAL_TOKEN } from './internal-token.js'; import { buildTaskSystemPrompt } from './task-context.js'; import type { TaskStore } from './task-store.js'; +/** Grace period for zero-turn inactive sessions — recently created ones stay visible. */ +const ZERO_TURN_GRACE_MS = 60 * 60 * 1000; // 1 hour + let _taskStore: TaskStore | null = null; export function setTaskStore(store: TaskStore): void { _taskStore = store; @@ -1515,8 +1518,7 @@ export function getSessionsCached(offset = 0, limit = SESSION_PAGE_SIZE) { // always show regardless of turn count. Recently created sessions // (< 1 hour) are kept even with no turns — they may still be starting. if (m.numTurns === 0 && m.promptCount === 0 && !m.isActive) { - const ONE_HOUR = 60 * 60 * 1000; - return Date.now() - m.createdAt < ONE_HOUR; + return Date.now() - m.createdAt < ZERO_TURN_GRACE_MS; } return true; }); From f0789f10b8c3d387147176117c392461c7cea676 Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 16 May 2026 22:09:15 +0100 Subject: [PATCH 3/3] fix(server): hoist ZERO_TURN_GRACE_MS constant and optimize Date.now() call Addresses Centaur review feedback on PR #326: - Move ZERO_TURN_GRACE_MS from chat.ts to constants.ts for better discoverability and reuse - Extract Date.now() before filter loop to avoid redundant calls on each iteration Tests remain passing (session-cache.test.ts covers grace period logic). Co-Authored-By: Claude Opus 4.6 --- server/chat.ts | 7 +++---- server/constants.ts | 4 ++++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/server/chat.ts b/server/chat.ts index be78b4d5..1ff5dd31 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -35,14 +35,12 @@ import { SESSION_PAGE_SIZE, SESSION_MESSAGES_LIMIT, USER_CLOSEOUT_TIMEOUT_MS, + ZERO_TURN_GRACE_MS, } from './constants.js'; import { INTERNAL_TOKEN } from './internal-token.js'; import { buildTaskSystemPrompt } from './task-context.js'; import type { TaskStore } from './task-store.js'; -/** Grace period for zero-turn inactive sessions — recently created ones stay visible. */ -const ZERO_TURN_GRACE_MS = 60 * 60 * 1000; // 1 hour - let _taskStore: TaskStore | null = null; export function setTaskStore(store: TaskStore): void { _taskStore = store; @@ -1512,13 +1510,14 @@ export async function getSessions(offset = 0, limit = SESSION_PAGE_SIZE) { * Returns the same shape as getSessions() for API compatibility. */ export function getSessionsCached(offset = 0, limit = SESSION_PAGE_SIZE) { + const now = Date.now(); const all = eventStore.listSessions().filter((m) => { // Hide sessions that were never used through Mitzo (e.g. automated // code review sessions discovered from filesystem). Active sessions // always show regardless of turn count. Recently created sessions // (< 1 hour) are kept even with no turns — they may still be starting. if (m.numTurns === 0 && m.promptCount === 0 && !m.isActive) { - return Date.now() - m.createdAt < ZERO_TURN_GRACE_MS; + return now - m.createdAt < ZERO_TURN_GRACE_MS; } return true; }); diff --git a/server/constants.ts b/server/constants.ts index 744c94b2..0b0aeb64 100644 --- a/server/constants.ts +++ b/server/constants.ts @@ -42,6 +42,10 @@ export const SHUTDOWN_GRACE_MS = 5_000; export const GUARD_STATS_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes export const WORKTREE_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours +// --- Session List --- +/** Grace period for zero-turn inactive sessions — recently created ones stay visible. */ +export const ZERO_TURN_GRACE_MS = 60 * 60 * 1000; // 1 hour + // --- Query loop --- // If the Agent SDK yields no events within this window, we treat the turn // as unreachable (e.g. model unavailable on the configured provider) and