From 9cf1c4a91cc3ab81179ca234dedbbf4d5f32fbb0 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Fri, 28 Aug 2026 11:19:49 +0800 Subject: [PATCH] fix: preserve session createdAt/lastActivityAt across a daemon restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both timestamps were stamped unconditionally in the Session constructor, so every restart re-dated every resumed session to the restart moment: this.createdAt = new Date().toISOString(); this.#lastActivityAt = this.createdAt; Two consequences. A weeks-old session reported as brand new. And because the session list's recency key is `lastActivityAt ?? createdAt`, every session tied on the same instant — so the attention ordering added in #300 collapsed back to insertion order on the first restart, which is exactly what it was built to replace. The values were already persisted in TranscriptMeta (createdAt, lastActivityAt) and already read: SessionManager's `resumeSortKey` sorts the resume pass by `meta.lastActivityAt`. So the manager iterated in the right order and each Session then overwrote the timestamps a moment later — the correct data was on disk, read, and discarded. SessionCreateOptions now carries both as optional fields, the constructor prefers them over `now`, and the resume call site passes the meta values. A new session is unaffected (no opts -> stamped fresh, activity falls back to creation), and meta written before `lastActivityAt` existed degrades to `createdAt` rather than to `now` — an ancient session must not sort as the most recently active one. Co-Authored-By: Claude Opus 5 (1M context) --- src/daemon/session-manager.ts | 7 ++ src/daemon/session.ts | 24 ++++- src/tests/session-timestamps-resume.test.ts | 109 ++++++++++++++++++++ 3 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 src/tests/session-timestamps-resume.test.ts diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index a016567..763dc7c 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -657,6 +657,13 @@ export class SessionManager { hooks: this.#hooks, identityManager: this.#identityManager, existingId: meta.sessionId, + // Identity timestamps are durable, not turn state. Without these the + // constructor re-stamps both to `now`, which re-dates every session + // on every restart and ties them all on recency — collapsing the + // attention ordering this pass is already sorted by (resumeSortKey + // reads the very same `meta.lastActivityAt`). + createdAt: meta.createdAt, + lastActivityAt: meta.lastActivityAt, memory: this.#memory, memoryMcp: this.#memoryMcp, mcpRegistry: this.#mcpRegistry, diff --git a/src/daemon/session.ts b/src/daemon/session.ts index de65a6a..9fe15de 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -178,6 +178,17 @@ export interface SessionCreateOptions { transcriptStore: TranscriptStore; identityManager?: AgentIdentityManager; existingId?: string; + /** + * Original creation time, restored on resume from `TranscriptMeta.createdAt`. + * Absent for a genuinely new session (stamped fresh below). + */ + createdAt?: string; + /** + * Last activity time, restored on resume from + * `TranscriptMeta.lastActivityAt`. Absent for a new session, which falls + * back to `createdAt` — the same relationship the live tracker maintains. + */ + lastActivityAt?: string; /** * Called once per session with the live model catalog the backend * supports (e.g. the Claude Code SDK's `supportedModels()`), tagged with @@ -691,8 +702,17 @@ export class Session { opts.initialMode.mode === "autonomous" ? opts.initialMode.maxTurns : undefined; } this.createdBy = opts.auth.sub; - this.createdAt = new Date().toISOString(); - this.#lastActivityAt = this.createdAt; + // Restored from TranscriptMeta on resume, stamped fresh only for a new + // session. Both were previously unconditional `now`, which meant every + // daemon restart re-dated every session: `createdAt` became the restart + // moment (so a weeks-old session reported as brand new) and, because + // recency is `lastActivityAt ?? createdAt`, every session tied on the same + // instant and the attention ordering collapsed to insertion order. The + // values were already on disk and already read — SessionManager's + // `resumeSortKey` sorts the resume pass by `meta.lastActivityAt` — the + // constructor just overwrote them a moment later. + this.createdAt = opts.createdAt ?? new Date().toISOString(); + this.#lastActivityAt = opts.lastActivityAt ?? this.createdAt; this.accountId = opts.auth.accountId; this.projectId = opts.auth.projectId; this.#store = opts.store; diff --git a/src/tests/session-timestamps-resume.test.ts b/src/tests/session-timestamps-resume.test.ts new file mode 100644 index 0000000..2fbfd3a --- /dev/null +++ b/src/tests/session-timestamps-resume.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { Store } from "../daemon/store.js"; +import { TranscriptStore } from "../daemon/transcript.js"; +import { Session } from "../daemon/session.js"; +import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; +import type { AuthContext } from "../protocol/types.js"; + +/** + * A resumed session must keep the timestamps it was created with. + * + * `createdAt` and `lastActivityAt` were both stamped unconditionally in the + * constructor, so every daemon restart re-dated every session to the restart + * moment. Two consequences: a weeks-old session reported as brand new, and — + * because the session list's recency key is `lastActivityAt ?? createdAt` — + * every session tied on the same instant, collapsing the attention ordering + * back to insertion order. Both values were already persisted in + * `TranscriptMeta` and already read (SessionManager's `resumeSortKey` sorts the + * resume pass by `meta.lastActivityAt`); the constructor just overwrote them. + */ + +const TEST_AUTH: AuthContext = { + sub: "user:test-timestamps", + scopes: [], + delegationDepth: 0, + accountId: "acc-ts", + projectId: "proj-ts", +}; + +let tmp: string; +let store: Store; +let transcriptStore: TranscriptStore; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "codeoid-ts-")); + store = new Store(join(tmp, "codeoid.db")); + transcriptStore = new TranscriptStore(join(tmp, "transcripts")); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +function make(opts: { createdAt?: string; lastActivityAt?: string } = {}): Session { + const id = randomUUID(); + store.createSession({ + id, + name: "ts-test", + workdir: tmp, + status: "idle", + createdBy: TEST_AUTH.sub, + createdAt: new Date().toISOString(), + attachedClients: 0, + accountId: TEST_AUTH.accountId!, + projectId: TEST_AUTH.projectId!, + }); + return new Session({ + name: "ts-test", + workdir: tmp, + auth: TEST_AUTH, + store, + transcriptStore, + existingId: id, + _testProvider: new MockSessionProvider(), + ...opts, + } as never); +} + +describe("session timestamps across resume", () => { + const ORIGIN = "2026-07-01T10:00:00.000Z"; + const ACTIVE = "2026-08-20T18:30:00.000Z"; + + it("preserves createdAt and lastActivityAt when resumed from meta", () => { + const s = make({ createdAt: ORIGIN, lastActivityAt: ACTIVE }); + expect(s.createdAt).toBe(ORIGIN); + expect(s.toInfo().lastActivityAt).toBe(ACTIVE); + }); + + it("stamps both fresh for a genuinely new session", () => { + const before = Date.now(); + const s = make(); + const created = Date.parse(s.createdAt); + expect(created).toBeGreaterThanOrEqual(before - 1000); + // A new session has no activity yet, so recency falls back to creation. + expect(s.toInfo().lastActivityAt).toBe(s.createdAt); + }); + + it("falls back to createdAt when only createdAt is restored", () => { + // Meta written before lastActivityAt existed — must not regress to `now`, + // which would sort an ancient session as the most recently active one. + const s = make({ createdAt: ORIGIN }); + expect(s.createdAt).toBe(ORIGIN); + expect(s.toInfo().lastActivityAt).toBe(ORIGIN); + }); + + it("keeps distinct sessions distinguishable after a simulated restart", () => { + // The actual regression: with both re-stamped to `now`, these collapse to + // the same instant and the attention ordering loses its recency signal. + const older = make({ createdAt: ORIGIN, lastActivityAt: "2026-08-01T00:00:00.000Z" }); + const newer = make({ createdAt: ORIGIN, lastActivityAt: "2026-08-25T00:00:00.000Z" }); + + const a = Date.parse(older.toInfo().lastActivityAt!); + const b = Date.parse(newer.toInfo().lastActivityAt!); + expect(b).toBeGreaterThan(a); + }); +});