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 5c11efac..1ff5dd31 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -35,6 +35,7 @@ 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'; @@ -689,7 +690,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 +701,7 @@ async function _startChatInner( isActive: true, ...(worktreePath ? { wtId } : {}), ...(options.telosTaskId ? { telosTaskId: options.telosTaskId } : {}), + ...(existingMeta ? { updatedAt: existingMeta.updatedAt } : {}), }); } @@ -1506,13 +1510,15 @@ 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. - // 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) { + return now - m.createdAt < ZERO_TURN_GRACE_MS; + } return true; }); const page = all.slice(offset, offset + limit); 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