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 d3e471a..9fc8bda 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -184,6 +184,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 @@ -697,8 +708,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); + }); +});