Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/daemon/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 22 additions & 2 deletions src/daemon/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
109 changes: 109 additions & 0 deletions src/tests/session-timestamps-resume.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading