diff --git a/.specs/features/open-resume-linkage/spec.md b/.specs/features/open-resume-linkage/spec.md new file mode 100644 index 0000000..70735e9 --- /dev/null +++ b/.specs/features/open-resume-linkage/spec.md @@ -0,0 +1,26 @@ +# Open resume linkage + +## Goal + +Resuming an interactive session with `codedeck open --resume` should revive its +existing CodeDeck row so the launcher keeps the same session and run identity. + +## Requirements + +- ORL-01: WHEN `session.adopt` receives a non-empty `resume` value and an open + session matches the same agent and native session id, THEN the daemon SHALL + reuse that row, keep its id and run id, set its status to `working`, update + its timestamp, and refresh model, effort, name, and cwd from supplied values. +- ORL-02: WHEN multiple open rows match ORL-01, THEN the daemon SHALL select + the row with the most recent update timestamp. +- ORL-03: IF the selected matching row has a non-terminal status and its pid is + alive under the same process identity, THEN the daemon SHALL create a new row + instead of taking over that live session. +- ORL-04: WHEN `resume` is absent or matches no row, THEN `session.adopt` SHALL + create a new row with `runId` equal to its new id. +- ORL-05: WHEN the daemon revives a row, THEN it SHALL clear stale process and + terminal state while keeping the row's worktree, branch, and base commit. +- ORL-06: WHEN `codedeck open` receives `--resume ` for any launcher, + THEN it SHALL pass that value in the `session.adopt` request. +- ORL-07: WHEN a revived row is released, THEN `session.release` SHALL mark it + terminal. diff --git a/src/cli/commands/open.ts b/src/cli/commands/open.ts index 6bd105b..b3b7db3 100644 --- a/src/cli/commands/open.ts +++ b/src/cli/commands/open.ts @@ -591,6 +591,7 @@ export function registerOpenCommand(program: Command): void { ...(openEffort !== undefined ? { effort: openEffort } : {}), cwd, name: role, + ...(opts.resume !== undefined ? { resume: opts.resume } : {}), }); const runId = adoptRes.session.id; diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts index d9bb8d4..037a122 100644 --- a/src/daemon/daemon.ts +++ b/src/daemon/daemon.ts @@ -435,11 +435,6 @@ class Daemon { return; } - let sessionId = generateSessionId(); - while (this.sessions.get(sessionId)) { - sessionId = generateSessionId(); - } - const cwd = path.resolve(cwdIn); let repository: string | undefined; let baseCommit = p.baseCommit; @@ -449,6 +444,46 @@ class Daemon { send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } }); return; } + + const matchingSession = typeof p.resume === "string" && p.resume.length > 0 + ? this.sessions.listOpenByNativeId(p.resume).find((session) => session.agent === agent) + : undefined; + if ( + matchingSession && + (isTerminalStatus(matchingSession.status) || !livePidIdentity(matchingSession)) + ) { + const now = new Date(); + this.sessions.setStatus(matchingSession.id, "working", { + ...(p.model !== undefined ? { model: p.model } : {}), + ...(p.effort !== undefined ? { effort: p.effort } : {}), + ...(p.name !== undefined ? { name: p.name } : {}), + ...(p.cwd !== undefined ? { cwd } : {}), + pid: null, + pidStartTime: null, + completedAt: null, + lastEvent: null, + failure: null, + updatedAt: now, + }); + const revived = this.sessions.get(matchingSession.id)!; + const event: AgentEvent = { + type: "session.started", + sessionId: revived.id, + timestamp: now.toISOString(), + agent, + nativeSessionId: p.resume, + }; + this.events.append(revived.id, event); + this.broadcast(revived.id, event); + send({ result: { session: revived } }); + break; + } + + let sessionId = generateSessionId(); + while (this.sessions.get(sessionId)) { + sessionId = generateSessionId(); + } + if (gitInfo) { repository = gitInfo.root; if (!baseCommit) baseCommit = gitInfo.head; diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts index 1a792c5..306197a 100644 --- a/src/daemon/protocol.ts +++ b/src/daemon/protocol.ts @@ -60,6 +60,7 @@ export interface AdoptSessionRequest { worktree?: string; branch?: string; baseCommit?: string; + resume?: string; }; } diff --git a/src/store/sessions.ts b/src/store/sessions.ts index 398cd7c..d37626c 100644 --- a/src/store/sessions.ts +++ b/src/store/sessions.ts @@ -99,6 +99,17 @@ function rowToSession(row: SessionRow): Session { /** Window for the default `ps` view: sessions updated within this are "recent". */ export const PS_RECENT_WINDOW_MS = 24 * 60 * 60 * 1000; +type SessionUpdate = Omit< + Partial, + "completedAt" | "failure" | "lastEvent" | "pid" | "pidStartTime" +> & { + completedAt?: Date | null; + failure?: FailureInfo | null; + lastEvent?: string | null; + pid?: number | null; + pidStartTime?: string | null; +}; + export class SessionStore { constructor(private db: DatabaseSync) {} @@ -205,7 +216,7 @@ export class SessionStore { return this.getByRunId(runId); } - update(id: string, patch: Partial & { status?: SessionStatus }): void { + update(id: string, patch: SessionUpdate): void { const existing = this.get(id); if (!existing) throw new Error(`Session ${id} not found`); const now = new Date().toISOString(); @@ -223,21 +234,29 @@ export class SessionStore { worktree: patch.worktree, branch: patch.branch, base_commit: patch.baseCommit, - pid: patch.pid, - completed_at: patch.completedAt ? (patch.completedAt as Date).toISOString() : undefined, + pid: patch.pid === undefined ? undefined : patch.pid ?? null, + completed_at: patch.completedAt === undefined + ? undefined + : patch.completedAt === null + ? null + : patch.completedAt.toISOString(), usage_input_tokens: patch.usage?.inputTokens, usage_output_tokens: patch.usage?.outputTokens, usage_cached_tokens: patch.usage?.cachedTokens, usage_cost: patch.usage?.cost, - last_event: patch.lastEvent, + last_event: patch.lastEvent === undefined ? undefined : patch.lastEvent ?? null, effort: patch.effort, fast: patch.fast === undefined ? undefined : patch.fast ? 1 : 0, sandbox: patch.sandbox, dangerously_bypass_approvals_and_sandbox: patch.dangerouslyBypassApprovalsAndSandbox === undefined ? undefined : patch.dangerouslyBypassApprovalsAndSandbox ? 1 : 0, - pid_start_time: patch.pidStartTime, + pid_start_time: patch.pidStartTime === undefined ? undefined : patch.pidStartTime ?? null, log_offset: patch.logOffset, stderr_offset: patch.stderrOffset, - failure: patch.failure === undefined ? undefined : JSON.stringify(patch.failure), + failure: patch.failure === undefined + ? undefined + : patch.failure === null + ? null + : JSON.stringify(patch.failure), origin: patch.origin, pending_message: patch.pendingMessage === undefined ? undefined : (patch.pendingMessage ?? null), pending_at: patch.pendingAt === undefined ? undefined : (patch.pendingAt ?? null), @@ -259,8 +278,8 @@ export class SessionStore { (this.db.prepare(`UPDATE sessions SET ${fields.join(", ")} WHERE id = ?`) as any).run(...(values as any)); } - setStatus(id: string, status: SessionStatus, extra?: Partial): void { - const patch: Partial & { status: SessionStatus } = { status, updatedAt: new Date(), ...extra }; + setStatus(id: string, status: SessionStatus, extra?: SessionUpdate): void { + const patch: SessionUpdate & { status: SessionStatus } = { status, updatedAt: new Date(), ...extra }; if (status === "completed" || status === "failed" || status === "stopped" || status === "orphaned" || status === "interrupted") { patch.completedAt = new Date(); } @@ -276,6 +295,13 @@ export class SessionStore { return row ? rowToSession(row) : null; } + listOpenByNativeId(nativeId: string): Session[] { + const rows = this.db.prepare( + `SELECT * FROM sessions WHERE origin = 'open' AND native_session_id = ? ORDER BY updated_at DESC, id ASC`, + ).all(nativeId) as unknown as SessionRow[]; + return rows.map(rowToSession); + } + queryUsage(params: UsageQueryParams = {}): UsageQueryResult { const { since, until } = resolveUsageDateRange(params); diff --git a/tests/open-action.test.ts b/tests/open-action.test.ts index e9e554c..368f1aa 100644 --- a/tests/open-action.test.ts +++ b/tests/open-action.test.ts @@ -34,6 +34,15 @@ function mockClaudeLaunch(): void { } describe("opencode dispatch", () => { + it("passes the resume value to session.adopt", async () => { + await runOpen(["reviewer", "--no-theme", "--resume", "native-session-789"]); + + const adopt = vi.mocked(IpcClient.prototype.request).mock.calls.find( + ([method]) => method === "session.adopt", + ); + expect(adopt?.[1]).toMatchObject({ resume: "native-session-789" }); + }); + it("guarantees the daemon before spawning, in order", async () => { await runOpen(["reviewer", "--no-theme"]); diff --git a/tests/session-adopt.test.ts b/tests/session-adopt.test.ts index 84e4c8a..a48b9c3 100644 --- a/tests/session-adopt.test.ts +++ b/tests/session-adopt.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { processStartTime, processAlive, killTree } from "../src/utils/process.js"; +import { processStartTime } from "../src/utils/process.js"; +import * as processUtils from "../src/utils/process.js"; import { defaultCapabilities } from "../src/core/capabilities.js"; import { Daemon } from "../src/daemon/daemon.js"; import type { Session, SessionStatus } from "../src/core/session.js"; @@ -33,6 +34,198 @@ async function callIpc( } describe("session.adopt", () => { + it("reuses the matching open row and refreshes supplied session details", async () => { + const daemon = new Daemon(); + const resume = "native-session-123"; + const previousUpdate = new Date("2026-01-01T00:00:00.000Z"); + seed(daemon, "s-reuse", "failed", { + runId: "existing-run", + origin: "open", + nativeSessionId: resume, + name: "old-name", + agent: "claude", + model: "old-model", + effort: "low", + cwd: "/old-cwd", + updatedAt: previousUpdate, + }); + seed(daemon, "s-other-agent", "completed", { + runId: "other-run", + origin: "open", + nativeSessionId: resume, + agent: "codex", + updatedAt: new Date("2026-02-01T00:00:00.000Z"), + }); + + const res = await callIpc(daemon, "session.adopt", { + agent: "claude", + resume, + model: "new-model", + effort: "high", + cwd: "/tmp", + name: "new-name", + }); + + expect(res.error).toBeUndefined(); + expect(res.result.session).toMatchObject({ + id: "s-reuse", + runId: "existing-run", + status: "working", + agent: "claude", + model: "new-model", + effort: "high", + cwd: "/tmp", + name: "new-name", + }); + expect(new Date(res.result.session.updatedAt).getTime()).toBeGreaterThan(previousUpdate.getTime()); + expect(seam(daemon).sessions.list(50, true)).toHaveLength(2); + expect(seam(daemon).events.last("s-reuse")).toMatchObject({ + type: "session.started", + sessionId: "s-reuse", + nativeSessionId: resume, + }); + }); + + it("chooses the most recently updated matching open row", async () => { + const daemon = new Daemon(); + const resume = "native-session-456"; + seed(daemon, "s-older", "completed", { + runId: "older-run", + origin: "open", + nativeSessionId: resume, + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }); + seed(daemon, "s-newer", "interrupted", { + runId: "newer-run", + origin: "open", + nativeSessionId: resume, + updatedAt: new Date("2026-02-01T00:00:00.000Z"), + }); + + const res = await callIpc(daemon, "session.adopt", { + agent: "claude", + resume, + cwd: "/tmp", + }); + + expect(res.result.session.id).toBe("s-newer"); + expect(res.result.session.runId).toBe("newer-run"); + expect(seam(daemon).sessions.list(50, true)).toHaveLength(2); + }); + + it("creates a new row instead of taking over a live matching session", async () => { + const daemon = new Daemon(); + const isAlive = vi.spyOn(processUtils, "processAlive").mockReturnValue(true); + vi.spyOn(processUtils, "processStartTime").mockReturnValue("current-process-start"); + seed(daemon, "s-live-resume", "working", { + runId: "live-run", + origin: "open", + nativeSessionId: "native-session-live", + pid: 45678, + pidStartTime: "current-process-start", + }); + + const res = await callIpc(daemon, "session.adopt", { + agent: "claude", + resume: "native-session-live", + cwd: "/tmp", + }); + + expect(res.error).toBeUndefined(); + expect(res.result.session.id).not.toBe("s-live-resume"); + expect(res.result.session.runId).toBe(res.result.session.id); + expect(seam(daemon).sessions.get("s-live-resume")?.status).toBe("working"); + expect(seam(daemon).sessions.list(50, true)).toHaveLength(2); + expect(isAlive).toHaveBeenCalledWith(45678); + }); + + it("creates a new row when resume is absent or has no matching row", async () => { + const daemon = new Daemon(); + seed(daemon, "s-existing", "completed", { + origin: "open", + nativeSessionId: "native-session-existing", + }); + + const absent = await callIpc(daemon, "session.adopt", { + agent: "claude", + cwd: "/tmp", + }); + const missing = await callIpc(daemon, "session.adopt", { + agent: "claude", + resume: "native-session-missing", + cwd: "/tmp", + }); + + expect(absent.result.session.id).not.toBe("s-existing"); + expect(absent.result.session.runId).toBe(absent.result.session.id); + expect(missing.result.session.id).not.toBe("s-existing"); + expect(missing.result.session.runId).toBe(missing.result.session.id); + expect(missing.result.session.id).not.toBe(absent.result.session.id); + expect(seam(daemon).sessions.list(50, true)).toHaveLength(3); + }); + + it("clears stale process and terminal fields while keeping worktree metadata", async () => { + const daemon = new Daemon(); + const completedAt = new Date("2026-01-01T00:00:00.000Z"); + seed(daemon, "s-cleanup", "interrupted", { + runId: "cleanup-run", + origin: "open", + nativeSessionId: "native-session-cleanup", + pid: 12345, + pidStartTime: "old-start-time", + completedAt, + lastEvent: "stale terminal event", + failure: { code: "HARNESS_CRASH", blame: "harness", retryable: true }, + worktree: "/tmp/worktree", + branch: "ra/old-branch", + baseCommit: "abc123", + }); + + const res = await callIpc(daemon, "session.adopt", { + agent: "claude", + resume: "native-session-cleanup", + cwd: "/tmp", + }); + + expect(res.result.session).toMatchObject({ + id: "s-cleanup", + runId: "cleanup-run", + status: "working", + worktree: "/tmp/worktree", + branch: "ra/old-branch", + baseCommit: "abc123", + }); + expect(res.result.session.pid).toBeUndefined(); + expect(res.result.session.pidStartTime).toBeUndefined(); + expect(res.result.session.completedAt).toBeUndefined(); + expect(res.result.session.lastEvent).toBeUndefined(); + expect(res.result.session.failure).toBeUndefined(); + }); + + it("releases a revived row as completed", async () => { + const daemon = new Daemon(); + seed(daemon, "s-release-revived", "completed", { + runId: "release-run", + origin: "open", + nativeSessionId: "native-session-release", + }); + + const adopted = await callIpc(daemon, "session.adopt", { + agent: "claude", + resume: "native-session-release", + cwd: "/tmp", + }); + expect(adopted.result.session.status).toBe("working"); + + const released = await callIpc(daemon, "session.release", { + id: "s-release-revived", + }); + + expect(released.error).toBeUndefined(); + expect(released.result.session.status).toBe("completed"); + expect(seam(daemon).events.last("s-release-revived")?.type).toBe("session.completed"); + }); + it("creates a head session with 4-hex canonical id, origin=open, and status=working", async () => { const daemon = new Daemon(); const res = await callIpc(daemon, "session.adopt", {