Skip to content

Commit 57f27ab

Browse files
dimakisclaude
andauthored
Fix session list timestamp reset and missing new sessions (#326)
* 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 <noreply@anthropic.com> * fix(server): address PR #326 review — hoist constant + add test coverage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 288d3b2 commit 57f27ab

3 files changed

Lines changed: 116 additions & 4 deletions

File tree

server/__tests__/session-cache.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,51 @@ describe('getSessionsCached', () => {
217217
cwd: '/projects/bar',
218218
});
219219
});
220+
221+
it('keeps a recent zero-turn inactive session within the grace period', async () => {
222+
const thirtyMinutesAgo = Date.now() - 30 * 60 * 1000;
223+
mockListSessionsMeta.mockReturnValue([
224+
{
225+
sessionId: 'sess-recent-zero',
226+
summary: 'Just created',
227+
numTurns: 0,
228+
promptCount: 0,
229+
isActive: false,
230+
createdAt: thirtyMinutesAgo,
231+
updatedAt: thirtyMinutesAgo,
232+
branch: null,
233+
cwd: null,
234+
},
235+
]);
236+
237+
const { getSessionsCached } = await import('../chat.js');
238+
const { sessions } = getSessionsCached();
239+
240+
expect(sessions).toHaveLength(1);
241+
expect(sessions[0].id).toBe('sess-recent-zero');
242+
});
243+
244+
it('hides an old zero-turn inactive session past the grace period', async () => {
245+
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
246+
mockListSessionsMeta.mockReturnValue([
247+
{
248+
sessionId: 'sess-old-zero',
249+
summary: 'Stale empty session',
250+
numTurns: 0,
251+
promptCount: 0,
252+
isActive: false,
253+
createdAt: twoHoursAgo,
254+
updatedAt: twoHoursAgo,
255+
branch: null,
256+
cwd: null,
257+
},
258+
]);
259+
260+
const { getSessionsCached } = await import('../chat.js');
261+
const { sessions } = getSessionsCached();
262+
263+
expect(sessions).toHaveLength(0);
264+
});
220265
});
221266

222267
describe('syncSessionTimestamps', () => {
@@ -347,6 +392,63 @@ describe('syncSessionTimestamps', () => {
347392
});
348393
});
349394

395+
describe('resume timestamp preservation', () => {
396+
it('preserves updatedAt when resuming an existing session', async () => {
397+
const originalUpdatedAt = Date.now() - 3600_000; // 1 hour ago
398+
const existingMeta = {
399+
sessionId: 'sess-resume',
400+
summary: 'Old session',
401+
updatedAt: originalUpdatedAt,
402+
createdAt: originalUpdatedAt - 7200_000,
403+
numTurns: 5,
404+
promptCount: 3,
405+
isActive: false,
406+
branch: 'main',
407+
cwd: '/projects/foo',
408+
manuallyRenamed: false,
409+
};
410+
mockGetSession.mockReturnValue(existingMeta);
411+
412+
// Simulate the resume upsert pattern from startSession (chat.ts L688-701):
413+
// const existingMeta = eventStore.getSession(id);
414+
// eventStore.upsertSession({ ..., ...(existingMeta ? { updatedAt: existingMeta.updatedAt } : {}) });
415+
const sessionId = 'sess-resume';
416+
const retrieved = mockEventStore.getSession(sessionId);
417+
mockEventStore.upsertSession({
418+
sessionId,
419+
cwd: '/projects/foo',
420+
mode: 'code',
421+
branch: 'main',
422+
...(retrieved ? { updatedAt: retrieved.updatedAt } : {}),
423+
});
424+
425+
expect(mockUpsertSession).toHaveBeenCalledWith(
426+
expect.objectContaining({
427+
sessionId: 'sess-resume',
428+
updatedAt: originalUpdatedAt,
429+
}),
430+
);
431+
});
432+
433+
it('omits updatedAt when resuming a session with no prior metadata', async () => {
434+
mockGetSession.mockReturnValue(null);
435+
436+
const sessionId = 'sess-new-resume';
437+
const retrieved = mockEventStore.getSession(sessionId);
438+
mockEventStore.upsertSession({
439+
sessionId,
440+
cwd: '/projects/foo',
441+
mode: 'code',
442+
branch: 'main',
443+
...(retrieved ? { updatedAt: retrieved.updatedAt } : {}),
444+
});
445+
446+
const call = mockUpsertSession.mock.calls[0][0];
447+
expect(call.sessionId).toBe('sess-new-resume');
448+
expect(call).not.toHaveProperty('updatedAt');
449+
});
450+
});
451+
350452
describe('hideSession persistence', () => {
351453
it('calls eventStore.hideSession to persist deletion', async () => {
352454
const { hideSession } = await import('../chat.js');

server/chat.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
SESSION_PAGE_SIZE,
3636
SESSION_MESSAGES_LIMIT,
3737
USER_CLOSEOUT_TIMEOUT_MS,
38+
ZERO_TURN_GRACE_MS,
3839
} from './constants.js';
3940
import { INTERNAL_TOKEN } from './internal-token.js';
4041
import { buildTaskSystemPrompt } from './task-context.js';
@@ -689,7 +690,9 @@ async function _startChatInner(
689690

690691
// Pre-register resumed sessions in EventStore so they're discoverable
691692
// even if the query loop dies before the first assistant event.
693+
// Preserve existing updatedAt so server restarts don't reset all timestamps.
692694
if (options.resume) {
695+
const existingMeta = eventStore.getSession(options.resume);
693696
eventStore.upsertSession({
694697
sessionId: options.resume,
695698
cwd,
@@ -698,6 +701,7 @@ async function _startChatInner(
698701
isActive: true,
699702
...(worktreePath ? { wtId } : {}),
700703
...(options.telosTaskId ? { telosTaskId: options.telosTaskId } : {}),
704+
...(existingMeta ? { updatedAt: existingMeta.updatedAt } : {}),
701705
});
702706
}
703707

@@ -1506,13 +1510,15 @@ export async function getSessions(offset = 0, limit = SESSION_PAGE_SIZE) {
15061510
* Returns the same shape as getSessions() for API compatibility.
15071511
*/
15081512
export function getSessionsCached(offset = 0, limit = SESSION_PAGE_SIZE) {
1513+
const now = Date.now();
15091514
const all = eventStore.listSessions().filter((m) => {
15101515
// Hide sessions that were never used through Mitzo (e.g. automated
15111516
// code review sessions discovered from filesystem). Active sessions
1512-
// always show regardless of turn count.
1513-
// Note: new sessions are created with is_active=1 by default (see EventStore
1514-
// schema), so a brand-new session will never be filtered out here.
1515-
if (m.numTurns === 0 && m.promptCount === 0 && !m.isActive) return false;
1517+
// always show regardless of turn count. Recently created sessions
1518+
// (< 1 hour) are kept even with no turns — they may still be starting.
1519+
if (m.numTurns === 0 && m.promptCount === 0 && !m.isActive) {
1520+
return now - m.createdAt < ZERO_TURN_GRACE_MS;
1521+
}
15161522
return true;
15171523
});
15181524
const page = all.slice(offset, offset + limit);

server/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ export const SHUTDOWN_GRACE_MS = 5_000;
4242
export const GUARD_STATS_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
4343
export const WORKTREE_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
4444

45+
// --- Session List ---
46+
/** Grace period for zero-turn inactive sessions — recently created ones stay visible. */
47+
export const ZERO_TURN_GRACE_MS = 60 * 60 * 1000; // 1 hour
48+
4549
// --- Query loop ---
4650
// If the Agent SDK yields no events within this window, we treat the turn
4751
// as unreachable (e.g. model unavailable on the configured provider) and

0 commit comments

Comments
 (0)