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
26 changes: 26 additions & 0 deletions .specs/features/open-resume-linkage/spec.md
Original file line number Diff line number Diff line change
@@ -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 <value>` 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.
1 change: 1 addition & 0 deletions src/cli/commands/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
45 changes: 40 additions & 5 deletions src/daemon/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/daemon/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export interface AdoptSessionRequest {
worktree?: string;
branch?: string;
baseCommit?: string;
resume?: string;
};
}

Expand Down
42 changes: 34 additions & 8 deletions src/store/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Session>,
"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) {}

Expand Down Expand Up @@ -205,7 +216,7 @@ export class SessionStore {
return this.getByRunId(runId);
}

update(id: string, patch: Partial<Session> & { 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();
Expand All @@ -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),
Expand All @@ -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<Session>): void {
const patch: Partial<Session> & { 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();
}
Expand All @@ -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);

Expand Down
9 changes: 9 additions & 0 deletions tests/open-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);

Expand Down
Loading
Loading