From 4c9dd6a9b9a2ed123248e23aadbf056548510f68 Mon Sep 17 00:00:00 2001 From: Seth Date: Mon, 6 Jul 2026 17:02:57 -0700 Subject: [PATCH 01/14] Protect live sessions during daemon updates --- .../coding-agent/src/cli/daemon-launch.ts | 40 ++++++++++--------- .../src/cli/daemon-stop-confirm.ts | 23 +++++------ packages/coding-agent/src/main.ts | 8 ++-- .../coding-agent/src/package-manager-cli.ts | 8 ++-- .../coding-agent/test/daemon-launch.test.ts | 26 +++++++++++- .../test/daemon-stop-confirm.test.ts | 22 ++++++---- 6 files changed, 81 insertions(+), 46 deletions(-) diff --git a/packages/coding-agent/src/cli/daemon-launch.ts b/packages/coding-agent/src/cli/daemon-launch.ts index 785e3d9a2b..07f379bd9a 100644 --- a/packages/coding-agent/src/cli/daemon-launch.ts +++ b/packages/coding-agent/src/cli/daemon-launch.ts @@ -132,13 +132,19 @@ export async function shutdownDaemonAndWait(socketPath: string): Promise 0; +export function isSessionAtRiskFromDaemonStop(summary: SessionSummary): boolean { + // Any live active session owns in-memory runtime state, subscriptions, queues, + // and scheduled work. Saved-only sessions have no activeSessionId and can be + // reloaded by the fresh daemon from disk. + return ( + summary.activeSessionId !== undefined || + summary.isStreaming || + summary.isCompacting || + summary.pendingMessageCount > 0 + ); } export async function probeRunningDaemonSessions(socketPath: string): Promise { @@ -159,12 +165,12 @@ export async function probeRunningDaemonSessions(socketPath: string): Promise { +// Saved-only sessions reload from disk on the fresh daemon. Live active sessions +// must not be terminated implicitly just because the CLI version changed. +async function shutdownStaleDaemonIfNoLiveSessions(socketPath: string): Promise { const client = new DaemonClient(socketPath); let connected = false; - let hasBusySessions = false; + let hasAtRiskSessions = false; let loadedSessionCount = 0; try { await client.connect(1000); @@ -172,10 +178,10 @@ async function shutdownStaleDaemonIfNotBusy(socketPath: string): Promise { @@ -31,8 +31,8 @@ export function pluralizeSessions(count: number): { noun: string; pronoun: strin } export interface DaemonSessionLossCopy { - /** Full sentence describing the busy sessions and what stopping the daemon does. */ - busyDetail(count: number): string; + /** Full sentence describing the at-risk sessions and what stopping the daemon does. */ + atRiskDetail(count: number): string; /** Full sentence for the reachable-but-unlistable case (work may be lost). */ unlistableDetail: string; /** Question appended after the detail when prompting at a TTY (before " [y/N]"). */ @@ -43,10 +43,9 @@ export interface DaemonSessionLossCopy { /** * Returns true when it is safe to proceed with stopping the daemon: it is not - * reachable, `force` is set, no sessions are busy, or the user confirmed at a - * TTY. Returns false to abort (busy/unlistable and either declined or non-TTY). - * Only busy sessions (streaming, compacting, or pending messages) lose work; - * idle loaded sessions reload from disk on the fresh daemon. + * reachable, `force` is set, no live sessions are at risk, or the user + * confirmed at a TTY. Returns false to abort (at-risk/unlistable and either + * declined or non-TTY). Saved-only sessions reload from disk on the fresh daemon. */ export async function confirmDaemonSessionLoss( probe: RunningDaemonProbe, @@ -61,11 +60,11 @@ export async function confirmDaemonSessionLoss( // Reachable but couldn't list sessions: assume work may be lost. detail = copy.unlistableDetail; } else { - const busySessions = probe.activeSessions.filter(isSessionBusy); - if (busySessions.length === 0) { + const atRiskSessions = probe.activeSessions.filter(isSessionAtRiskFromDaemonStop); + if (atRiskSessions.length === 0) { return true; } - detail = copy.busyDetail(busySessions.length); + detail = copy.atRiskDetail(atRiskSessions.length); } if (!process.stdin.isTTY) { console.error(chalk.red(`${detail} ${copy.nonTtyHint}`)); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index a6da98bd07..eb5108db94 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -369,12 +369,12 @@ async function promptConfirm(message: string): Promise { }); } -// Only busy sessions (streaming, compacting, or pending messages) lose work; -// idle loaded sessions reload from disk on the fresh daemon. +// Saved-only sessions reload from disk on the fresh daemon. Live active sessions +// own runtime state and must not be terminated by startup takeover unless forced. const STARTUP_SESSION_LOSS_COPY: DaemonSessionLossCopy = { - busyDetail(count) { + atRiskDetail(count) { const { noun, pronoun } = pluralizeSessions(count); - return `A background daemon from a different prime-agent version is running with ${count} busy ${noun}. Stopping it will terminate ${pronoun}.`; + return `A background daemon from a different prime-agent version is running with ${count} live ${noun}. Stopping it will terminate ${pronoun}.`; }, unlistableDetail: "A background daemon from a different prime-agent version is running and its sessions could not be listed. Stopping it may terminate active sessions.", diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index aff93faf67..a99ac7fc7d 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -354,12 +354,12 @@ async function runSelfUpdate(command: SelfUpdateCommand): Promise { } } -// Only busy sessions (streaming, compacting, or pending messages) would lose work; -// idle loaded sessions reload from disk on the fresh daemon. +// Saved-only sessions reload from disk on the fresh daemon. Live active sessions +// own runtime state and must not be terminated by update unless forced. const UPDATE_SESSION_LOSS_COPY: DaemonSessionLossCopy = { - busyDetail(count) { + atRiskDetail(count) { const { noun, pronoun } = pluralizeSessions(count); - return `The running daemon has ${count} busy ${noun}. Updating will stop the daemon and terminate ${pronoun}.`; + return `The running daemon has ${count} live ${noun}. Updating will stop the daemon and terminate ${pronoun}.`; }, unlistableDetail: "A running daemon's sessions could not be listed. Updating will stop the daemon and may terminate active sessions.", diff --git a/packages/coding-agent/test/daemon-launch.test.ts b/packages/coding-agent/test/daemon-launch.test.ts index d7c9628a71..529ee3b2ca 100644 --- a/packages/coding-agent/test/daemon-launch.test.ts +++ b/packages/coding-agent/test/daemon-launch.test.ts @@ -3,7 +3,12 @@ import { createServer, type Server, type Socket } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { probeRunningDaemonSessions, shutdownDaemonAndWait } from "../src/cli/daemon-launch.js"; +import { + isSessionAtRiskFromDaemonStop, + probeRunningDaemonSessions, + shutdownDaemonAndWait, +} from "../src/cli/daemon-launch.js"; +import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js"; interface FakeDaemonOptions { /** Sessions returned for a `list` command. */ @@ -112,6 +117,25 @@ describe("probeRunningDaemonSessions", () => { }); }); +describe("isSessionAtRiskFromDaemonStop", () => { + function session(overrides: Partial): SessionSummary { + return { + isStreaming: false, + isCompacting: false, + pendingMessageCount: 0, + ...overrides, + } as unknown as SessionSummary; + } + + it("protects idle live sessions from daemon replacement", () => { + expect(isSessionAtRiskFromDaemonStop(session({ activeSessionId: "live-idle" }))).toBe(true); + }); + + it("does not protect saved-only idle sessions", () => { + expect(isSessionAtRiskFromDaemonStop(session({ activeSessionId: undefined }))).toBe(false); + }); +}); + describe("shutdownDaemonAndWait", () => { const cleanups: Array<() => Promise> = []; afterEach(async () => { diff --git a/packages/coding-agent/test/daemon-stop-confirm.test.ts b/packages/coding-agent/test/daemon-stop-confirm.test.ts index 2660309ce8..425e10208c 100644 --- a/packages/coding-agent/test/daemon-stop-confirm.test.ts +++ b/packages/coding-agent/test/daemon-stop-confirm.test.ts @@ -8,7 +8,7 @@ import { import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js"; const COPY: DaemonSessionLossCopy = { - busyDetail: (count) => `busy:${count}`, + atRiskDetail: (count) => `at-risk:${count}`, unlistableDetail: "unlistable", question: "Continue?", nonTtyHint: "hint", @@ -51,21 +51,29 @@ describe("confirmDaemonSessionLoss", () => { expect(await confirmDaemonSessionLoss(probe, { force: true, copy: COPY })).toBe(true); }); - it("proceeds without prompting when no session is busy", async () => { + it("proceeds without prompting when there are no live active sessions", async () => { setTTY(true); + const probe: RunningDaemonProbe = { reachable: true, activeSessions: [] }; + expect(await confirmDaemonSessionLoss(probe, { force: false, copy: COPY })).toBe(true); + }); + + it("aborts without prompting when not at a TTY and a live active session is idle", async () => { + setTTY(false); const probe: RunningDaemonProbe = { reachable: true, - activeSessions: [session({ isStreaming: false }), session({ pendingMessageCount: 0 })], + activeSessions: [session({ activeSessionId: "live-idle", isStreaming: false, pendingMessageCount: 0 })], }; - expect(await confirmDaemonSessionLoss(probe, { force: false, copy: COPY })).toBe(true); + expect(await confirmDaemonSessionLoss(probe, { force: false, copy: COPY })).toBe(false); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("hint")); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("at-risk:1")); }); - it("aborts without prompting when not at a TTY and a session is busy", async () => { + it("aborts without prompting when not at a TTY and a session has queued work", async () => { setTTY(false); const probe: RunningDaemonProbe = { reachable: true, activeSessions: [session({ pendingMessageCount: 2 })] }; expect(await confirmDaemonSessionLoss(probe, { force: false, copy: COPY })).toBe(false); expect(console.error).toHaveBeenCalledWith(expect.stringContaining("hint")); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("busy:1")); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("at-risk:1")); }); it("aborts without prompting when not at a TTY and sessions cannot be listed", async () => { @@ -75,7 +83,7 @@ describe("confirmDaemonSessionLoss", () => { expect(console.error).toHaveBeenCalledWith(expect.stringContaining("unlistable")); }); - it("treats compacting and pending-message sessions as busy", async () => { + it("treats compacting, pending-message, and streaming sessions as at risk", async () => { setTTY(false); for (const overrides of [{ isCompacting: true }, { pendingMessageCount: 1 }, { isStreaming: true }]) { vi.mocked(console.error).mockClear(); From c1cfff2e80e9e39555d4081f8f142c0d8e6a748c Mon Sep 17 00:00:00 2001 From: Prime Intellect Date: Mon, 6 Jul 2026 17:38:37 -0700 Subject: [PATCH 02/14] Add Claude Sonnet 5 to Prime Inference --- packages/ai/CHANGELOG.md | 2 ++ packages/ai/scripts/generate-models.ts | 3 +- packages/ai/src/models.generated.ts | 19 ++++++++++++ .../ai/test/prime-inference-models.test.ts | 30 +++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index a5c13ee228..33452f6c59 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Added Claude Sonnet 5 to the curated Prime Inference model catalog. + ## [0.2.6] - 2026-07-06 ## [0.2.5] - 2026-07-06 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index f9ec8f30ec..6bcafde48a 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -133,6 +133,7 @@ const PRIME_INFERENCE_MODEL_METADATA: Record, + "anthropic/claude-sonnet-5": { + id: "anthropic/claude-sonnet-5", + name: "Claude Sonnet 5", + api: "openai-completions", + provider: "prime-inference", + baseUrl: "https://api.pinference.ai/api/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","max":"max"}, + input: ["text", "image"], + cost: { + input: 2, + output: 10, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "deepseek/deepseek-v3.2": { id: "deepseek/deepseek-v3.2", name: "Deepseek V3.2", diff --git a/packages/ai/test/prime-inference-models.test.ts b/packages/ai/test/prime-inference-models.test.ts index cb5e0bc9b0..92a7d678d8 100644 --- a/packages/ai/test/prime-inference-models.test.ts +++ b/packages/ai/test/prime-inference-models.test.ts @@ -24,6 +24,7 @@ describe("Prime Inference models", () => { expect.arrayContaining([ "anthropic/claude-opus-4.7", "anthropic/claude-opus-4.8", + "anthropic/claude-sonnet-5", "deepseek/deepseek-v4-pro", "minimax/minimax-m3", "moonshotai/kimi-k2.7-code", @@ -69,6 +70,35 @@ describe("Prime Inference models", () => { }); }); + it("registers Claude Sonnet 5 on Prime Inference", () => { + const model = getModel("prime-inference", "anthropic/claude-sonnet-5"); + + expect(model).toBeDefined(); + expect(model.api).toBe("openai-completions"); + expect(model.provider).toBe("prime-inference"); + expect(model.baseUrl).toBe("https://api.pinference.ai/api/v1"); + expect(model.reasoning).toBe(true); + expect(model.thinkingLevelMap).toEqual({ xhigh: "xhigh", max: "max" }); + expect(getSupportedThinkingLevels(model)).toContain("xhigh"); + expect(getSupportedThinkingLevels(model)).toContain("max"); + expect(model.input).toEqual(["text", "image"]); + expect(model.contextWindow).toBe(200000); + expect(model.maxTokens).toBe(128000); + expect(model.cost).toEqual({ + input: 2, + output: 10, + cacheRead: 0, + cacheWrite: 0, + }); + expect(model.compat).toEqual({ + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: true, + maxTokensField: "max_tokens", + supportsStrictMode: false, + }); + }); + it("marks known reasoning-capable Prime Inference model families", () => { const opus48 = getModel("prime-inference", "anthropic/claude-opus-4.8"); expect(opus48.reasoning).toBe(true); From 1f4a38759a67f04c0789dfedbd2bcd2e7c44da1c Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 7 Jul 2026 13:07:05 -0700 Subject: [PATCH 03/14] fix(coding-agent): restore daemon sessions after update --- packages/coding-agent/CHANGELOG.md | 2 + .../coding-agent/src/cli/daemon-launch.ts | 188 ++++++++++++++---- .../src/cli/daemon-stop-confirm.ts | 10 +- packages/coding-agent/src/main.ts | 21 +- .../coding-agent/src/package-manager-cli.ts | 36 ++-- .../coding-agent/test/daemon-launch.test.ts | 145 +++++++++++++- .../test/daemon-stop-confirm.test.ts | 33 ++- .../test/package-command-paths.test.ts | 96 +++++++++ 8 files changed, 447 insertions(+), 84 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 81c0cb5a43..80284365bc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Fixed daemon self-updates reopening previous restorable live sessions after relaunch. + ## [0.2.6] - 2026-07-06 - Fixed the installer splash flickering during animation and resize by stabilizing full-screen redraws and removing misleading synthetic percentages ([ENG-4481](https://linear.app/primeintellect/issue/ENG-4481/installer-screen-is-unstable-and-flickery)). diff --git a/packages/coding-agent/src/cli/daemon-launch.ts b/packages/coding-agent/src/cli/daemon-launch.ts index 07f379bd9a..c1ce4e5576 100644 --- a/packages/coding-agent/src/cli/daemon-launch.ts +++ b/packages/coding-agent/src/cli/daemon-launch.ts @@ -9,7 +9,7 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { appendRotatingLog, expandTildePath, getClientErrorLogPath, VERSION } from "../config.js"; import { DaemonClient } from "../modes/daemon/daemon-client.js"; import { DAEMON_PROTOCOL_VERSION } from "../modes/daemon/daemon-protocol.js"; @@ -135,18 +135,88 @@ export async function shutdownDaemonAndWait(socketPath: string): Promise 0 + ); +} + +export function isSessionReopenableAfterDaemonStop(summary: SessionSummary): boolean { return ( - summary.activeSessionId !== undefined || - summary.isStreaming || - summary.isCompacting || - summary.pendingMessageCount > 0 + summary.activeSessionId !== undefined && summary.runtimeKind !== "subagent" && summary.sessionFile !== undefined ); } +export function isSessionRestorableAfterDaemonStop(summary: SessionSummary): boolean { + return isSessionReopenableAfterDaemonStop(summary) && !hasSessionVolatileWorkForDaemonStop(summary); +} + +export function isSessionAtRiskFromDaemonStop(summary: SessionSummary): boolean { + if (summary.activeSessionId === undefined) { + return hasSessionVolatileWorkForDaemonStop(summary); + } + return !isSessionRestorableAfterDaemonStop(summary); +} + +function restoreCandidateSessionFiles(sessions: readonly SessionSummary[]): string[] { + return [ + ...new Set( + sessions + .filter(isSessionRestorableAfterDaemonStop) + .map((session) => session.sessionFile) + .filter((sessionFile): sessionFile is string => sessionFile !== undefined) + .map((sessionFile) => resolve(sessionFile)), + ), + ]; +} + +export async function restoreDaemonSessionSummaries( + socketPath: string, + sessions: readonly SessionSummary[], +): Promise { + const sessionFiles = restoreCandidateSessionFiles(sessions); + if (sessionFiles.length === 0) { + return { restored: 0, total: 0, failed: [] }; + } + + const client = new DaemonClient(socketPath); + await client.connect(3000); + let restored = 0; + const failed: DaemonSessionRestoreFailure[] = []; + try { + for (const sessionFile of sessionFiles) { + const response = await client.request({ + type: "create", + sessionPath: sessionFile, + config: { + sessionDir: dirname(sessionFile), + }, + }); + if (response.success) { + restored++; + } else { + failed.push({ sessionFile, error: response.error }); + } + } + } finally { + client.close(); + } + return { restored, total: sessionFiles.length, failed }; +} + export async function probeRunningDaemonSessions(socketPath: string): Promise { const client = new DaemonClient(socketPath); try { @@ -165,23 +235,25 @@ export async function probeRunningDaemonSessions(socketPath: string): Promise { +interface StaleDaemonShutdownResult { + stopped: boolean; + restoreSessions: SessionSummary[]; +} + +// Sessions with volatile in-memory work still block implicit replacement. Idle +// persisted top-level sessions can be reopened after the fresh daemon starts. +async function shutdownStaleDaemonForReplacement(socketPath: string): Promise { const client = new DaemonClient(socketPath); let connected = false; - let hasAtRiskSessions = false; - let loadedSessionCount = 0; + let summaries: SessionSummary[] | undefined; try { await client.connect(1000); connected = true; try { - const summaries = await listActiveDaemonSessionSummaries(client); - loadedSessionCount = summaries.length; - hasAtRiskSessions = summaries.some(isSessionAtRiskFromDaemonStop); + summaries = await listActiveDaemonSessionSummaries(client); } catch { - // Couldn't confirm idleness: treat as active rather than risk interrupting work. - hasAtRiskSessions = true; + // Couldn't confirm the session state: treat as active rather than risk interrupting work. + summaries = undefined; } } catch { // Couldn't reach it to inspect; don't send a blind shutdown, just verify below. @@ -190,28 +262,25 @@ async function shutdownStaleDaemonIfNoLiveSessions(socketPath: string): Promise< } if (!connected) { - return waitForDaemonGone(socketPath); + return { stopped: await waitForDaemonGone(socketPath), restoreSessions: [] }; } - if (hasAtRiskSessions) { - logDaemonLaunch(`refusing to replace stale daemon on ${socketPath}: live active session(s) present`); - return false; + if (!summaries) { + logDaemonLaunch(`refusing to replace stale daemon on ${socketPath}: session list unavailable`); + return { stopped: false, restoreSessions: [] }; } - logDaemonLaunch(`replacing stale daemon on ${socketPath}: ${loadedSessionCount} saved-only session(s) will reload`); - return shutdownDaemonAndWait(socketPath); -} - -async function ensureDaemonRunning(socketPath: string, spawnCwd?: string): Promise { - const probe = await probeDaemonVersion(socketPath); - if (probe === "current") { - return; - } - if (probe === "stale") { - const stopped = await shutdownStaleDaemonIfNoLiveSessions(socketPath); - if (!stopped) { - throw new StaleDaemonError(socketPath); - } + const atRiskSessions = summaries.filter(isSessionAtRiskFromDaemonStop); + if (atRiskSessions.length > 0) { + logDaemonLaunch(`refusing to replace stale daemon on ${socketPath}: unrestorable live session(s) present`); + return { stopped: false, restoreSessions: [] }; } + const restoreSessions = summaries.filter(isSessionRestorableAfterDaemonStop); + logDaemonLaunch( + `replacing stale daemon on ${socketPath}: ${restoreSessions.length} live session(s) will be reopened`, + ); + return { stopped: await shutdownDaemonAndWait(socketPath), restoreSessions }; +} +async function spawnDaemonAndWait(socketPath: string, spawnCwd?: string): Promise { const entrypoint = process.argv[1]; if (!entrypoint) { throw new Error("Cannot determine current CLI entrypoint for daemon launch"); @@ -242,6 +311,51 @@ async function ensureDaemonRunning(socketPath: string, spawnCwd?: string): Promi throw new Error(`Timed out waiting for daemon to start on ${socketPath}`); } +export async function relaunchDaemonAndRestoreSessions( + socketPath: string, + sessions: readonly SessionSummary[], + spawnCwd?: string, +): Promise { + const stopped = await shutdownDaemonAndWait(socketPath); + if (!stopped) { + throw new Error(`Could not stop daemon on ${socketPath}`); + } + await spawnDaemonAndWait(socketPath, spawnCwd); + return restoreDaemonSessionSummaries(socketPath, sessions); +} + +export async function spawnDaemonAndRestoreSessions( + socketPath: string, + sessions: readonly SessionSummary[], + spawnCwd?: string, +): Promise { + await spawnDaemonAndWait(socketPath, spawnCwd); + return restoreDaemonSessionSummaries(socketPath, sessions); +} + +async function ensureDaemonRunning(socketPath: string, spawnCwd?: string): Promise { + const probe = await probeDaemonVersion(socketPath); + if (probe === "current") { + return; + } + let restoreSessions: SessionSummary[] = []; + if (probe === "stale") { + const result = await shutdownStaleDaemonForReplacement(socketPath); + if (!result.stopped) { + throw new StaleDaemonError(socketPath); + } + restoreSessions = result.restoreSessions; + } + + const restoreResult = await spawnDaemonAndRestoreSessions(socketPath, restoreSessions, spawnCwd); + if (restoreResult.failed.length > 0) { + logDaemonLaunch( + `restored ${restoreResult.restored}/${restoreSessions.length} session(s) after daemon replacement; ` + + `${restoreResult.failed.length} failed`, + ); + } +} + const ensurePromises = new Map>(); /** diff --git a/packages/coding-agent/src/cli/daemon-stop-confirm.ts b/packages/coding-agent/src/cli/daemon-stop-confirm.ts index 9055b902f5..a5e1563ca6 100644 --- a/packages/coding-agent/src/cli/daemon-stop-confirm.ts +++ b/packages/coding-agent/src/cli/daemon-stop-confirm.ts @@ -2,8 +2,9 @@ * Shared confirmation for stopping a running daemon that has live sessions. * * Both `prime-agent update --self` and interactive startup (when taking over a - * stale-version daemon) need to ask before discarding live active sessions. - * They keep the same safety semantics here and only vary the wording via `copy`. + * stale-version daemon) need to ask before discarding live active sessions that + * cannot be restored after the daemon restarts. They keep the same safety + * semantics here and only vary the wording via `copy`. * * Kept out of daemon-launch.ts so the early fire-and-forget launch path stays * light on imports (no readline/chalk); this module is only reached on the @@ -31,7 +32,7 @@ export function pluralizeSessions(count: number): { noun: string; pronoun: strin } export interface DaemonSessionLossCopy { - /** Full sentence describing the at-risk sessions and what stopping the daemon does. */ + /** Full sentence describing the unrestorable sessions and what stopping the daemon does. */ atRiskDetail(count: number): string; /** Full sentence for the reachable-but-unlistable case (work may be lost). */ unlistableDetail: string; @@ -45,7 +46,8 @@ export interface DaemonSessionLossCopy { * Returns true when it is safe to proceed with stopping the daemon: it is not * reachable, `force` is set, no live sessions are at risk, or the user * confirmed at a TTY. Returns false to abort (at-risk/unlistable and either - * declined or non-TTY). Saved-only sessions reload from disk on the fresh daemon. + * declined or non-TTY). Restorable top-level sessions are reopened after the + * fresh daemon starts. */ export async function confirmDaemonSessionLoss( probe: RunningDaemonProbe, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index eb5108db94..12a8e489d1 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -18,8 +18,8 @@ import { isDaemonSessionSummary, listActiveDaemonSessionSummaries, probeRunningDaemonSessions, + relaunchDaemonAndRestoreSessions, StaleDaemonError, - shutdownDaemonAndWait, } from "./cli/daemon-launch.js"; import { confirmDaemonSessionLoss, type DaemonSessionLossCopy, pluralizeSessions } from "./cli/daemon-stop-confirm.js"; import { processFileArguments } from "./cli/file-processor.js"; @@ -369,12 +369,12 @@ async function promptConfirm(message: string): Promise { }); } -// Saved-only sessions reload from disk on the fresh daemon. Live active sessions -// own runtime state and must not be terminated by startup takeover unless forced. +// Idle persisted sessions are reopened after startup takeover. Sessions with +// volatile in-memory work still require confirmation because they cannot be restored. const STARTUP_SESSION_LOSS_COPY: DaemonSessionLossCopy = { atRiskDetail(count) { const { noun, pronoun } = pluralizeSessions(count); - return `A background daemon from a different prime-agent version is running with ${count} live ${noun}. Stopping it will terminate ${pronoun}.`; + return `A background daemon from a different prime-agent version is running with ${count} active ${noun} that cannot be restored. Stopping it will terminate ${pronoun}.`; }, unlistableDetail: "A background daemon from a different prime-agent version is running and its sessions could not be listed. Stopping it may terminate active sessions.", @@ -399,21 +399,14 @@ async function takeOverStaleDaemonOrExit(socketPath: string): Promise { } } -// Saved-only sessions reload from disk on the fresh daemon. Live active sessions -// own runtime state and must not be terminated by update unless forced. +// Idle persisted sessions are reopened after the update relaunch. Sessions with +// volatile in-memory work still require confirmation because they cannot be restored. const UPDATE_SESSION_LOSS_COPY: DaemonSessionLossCopy = { atRiskDetail(count) { const { noun, pronoun } = pluralizeSessions(count); - return `The running daemon has ${count} live ${noun}. Updating will stop the daemon and terminate ${pronoun}.`; + return `The running daemon has ${count} active ${noun} that cannot be restored. Updating will stop the daemon and terminate ${pronoun}.`; }, unlistableDetail: "A running daemon's sessions could not be listed. Updating will stop the daemon and may terminate active sessions.", @@ -372,19 +372,27 @@ function confirmDaemonSessionLossBeforeUpdate(probe: RunningDaemonProbe, force: return confirmDaemonSessionLoss(probe, { force, copy: UPDATE_SESSION_LOSS_COPY }); } -async function restartDaemonAfterSelfUpdate(socketPath: string, daemonWasRunning: boolean): Promise { - if (!daemonWasRunning) { +function reportDaemonSessionRestoreWarnings(result: DaemonSessionRestoreResult): void { + if (result.failed.length === 0) { return; } - const stopped = await shutdownDaemonAndWait(socketPath); - if (!stopped) { - console.error( - chalk.yellow(`Warning: could not stop the old daemon on ${socketPath}; it will be replaced on next launch.`), - ); + console.error( + chalk.yellow( + `Warning: restored ${result.restored}/${result.total} daemon session(s), but ${result.failed.length} session(s) failed to reopen.`, + ), + ); + for (const failure of result.failed) { + console.error(chalk.dim(` ${failure.sessionFile}: ${failure.error}`)); + } +} + +async function restartDaemonAfterSelfUpdate(socketPath: string, daemonProbe: RunningDaemonProbe): Promise { + if (!daemonProbe.reachable) { return; } try { - await ensureInteractiveDaemonRunning(socketPath); + const restoreResult = await relaunchDaemonAndRestoreSessions(socketPath, daemonProbe.activeSessions ?? []); + reportDaemonSessionRestoreWarnings(restoreResult); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); console.error( @@ -583,7 +591,7 @@ export async function handlePackageCommand(args: string[]): Promise { return true; } console.log(chalk.green(`Updated ${APP_NAME}`)); - await restartDaemonAfterSelfUpdate(daemonSocketPath, daemonProbe.reachable); + await restartDaemonAfterSelfUpdate(daemonSocketPath, daemonProbe); } return true; } diff --git a/packages/coding-agent/test/daemon-launch.test.ts b/packages/coding-agent/test/daemon-launch.test.ts index 529ee3b2ca..89e36edeaa 100644 --- a/packages/coding-agent/test/daemon-launch.test.ts +++ b/packages/coding-agent/test/daemon-launch.test.ts @@ -1,11 +1,13 @@ import { mkdtempSync, rmSync } from "node:fs"; import { createServer, type Server, type Socket } from "node:net"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { isSessionAtRiskFromDaemonStop, + isSessionRestorableAfterDaemonStop, probeRunningDaemonSessions, + restoreDaemonSessionSummaries, shutdownDaemonAndWait, } from "../src/cli/daemon-launch.js"; import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js"; @@ -17,6 +19,10 @@ interface FakeDaemonOptions { failList?: boolean; /** When false, the server ignores `shutdown` and stays up. */ respondToShutdown?: boolean; + /** Captures `create` commands sent by restore helpers. */ + createCommands?: Array>; + /** Session files whose restore should fail. */ + failCreateSessionFiles?: string[]; } interface FakeDaemon { @@ -44,7 +50,10 @@ async function startFakeDaemon(options: FakeDaemonOptions = {}): Promise; if (command.type === "list") { send(socket, { type: "response", @@ -54,6 +63,28 @@ async function startFakeDaemon(options: FakeDaemonOptions = {}): Promise { }); }); -describe("isSessionAtRiskFromDaemonStop", () => { +function sessionSummary(overrides: Partial): SessionSummary { + return { + id: "session", + lifecycle: "live", + activity: "idle", + runtimeKind: "top-level", + activeSessionId: "active", + sessionId: "session", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp", + isStreaming: false, + isCompacting: false, + attachedClients: 0, + messageCount: 1, + pendingMessageCount: 0, + ...overrides, + }; +} + +describe("daemon stop session classification", () => { function session(overrides: Partial): SessionSummary { - return { - isStreaming: false, - isCompacting: false, - pendingMessageCount: 0, - ...overrides, - } as unknown as SessionSummary; + return sessionSummary(overrides); } - it("protects idle live sessions from daemon replacement", () => { - expect(isSessionAtRiskFromDaemonStop(session({ activeSessionId: "live-idle" }))).toBe(true); + it("treats idle persisted top-level sessions as restorable", () => { + const summary = session({ activeSessionId: "live-idle" }); + expect(isSessionRestorableAfterDaemonStop(summary)).toBe(true); + expect(isSessionAtRiskFromDaemonStop(summary)).toBe(false); + }); + + it("protects live sessions that cannot be reopened", () => { + expect(isSessionAtRiskFromDaemonStop(session({ activeSessionId: "sub", runtimeKind: "subagent" }))).toBe(true); + expect(isSessionAtRiskFromDaemonStop(session({ activeSessionId: "missing-file", sessionFile: undefined }))).toBe( + true, + ); + }); + + it("protects sessions with volatile in-memory work", () => { + for (const overrides of [ + { isStreaming: true }, + { isCompacting: true }, + { isBashRunning: true }, + { pendingMessageCount: 1 }, + ] satisfies Partial[]) { + expect(isSessionAtRiskFromDaemonStop(session({ activeSessionId: "busy", ...overrides }))).toBe(true); + } }); it("does not protect saved-only idle sessions", () => { @@ -136,6 +201,64 @@ describe("isSessionAtRiskFromDaemonStop", () => { }); }); +describe("restoreDaemonSessionSummaries", () => { + const cleanups: Array<() => Promise> = []; + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((fn) => fn())); + }); + + it("reopens each distinct restorable session file", async () => { + const createCommands: Array> = []; + const daemon = await startFakeDaemon({ createCommands }); + cleanups.push(daemon.close); + const firstSessionFile = join(tmpdir(), "pa-session-restore-a.jsonl"); + const secondSessionFile = join(tmpdir(), "pa-session-restore-b.jsonl"); + + await expect( + restoreDaemonSessionSummaries(daemon.socketPath, [ + sessionSummary({ activeSessionId: "a", sessionFile: firstSessionFile }), + sessionSummary({ activeSessionId: "a-duplicate", sessionFile: firstSessionFile }), + sessionSummary({ activeSessionId: "b", sessionFile: secondSessionFile }), + sessionSummary({ activeSessionId: "busy", sessionFile: join(tmpdir(), "busy.jsonl"), isStreaming: true }), + sessionSummary({ + activeSessionId: "sub", + sessionFile: join(tmpdir(), "sub.jsonl"), + runtimeKind: "subagent", + }), + ]), + ).resolves.toEqual({ restored: 2, total: 2, failed: [] }); + + expect(createCommands.map((command) => command.sessionPath)).toEqual([firstSessionFile, secondSessionFile]); + expect(createCommands.map((command) => command.config)).toEqual([ + { sessionDir: dirname(firstSessionFile) }, + { sessionDir: dirname(secondSessionFile) }, + ]); + }); + + it("reports restore failures and continues with later sessions", async () => { + const createCommands: Array> = []; + const failedSessionFile = join(tmpdir(), "pa-session-restore-fail.jsonl"); + const restoredSessionFile = join(tmpdir(), "pa-session-restore-ok.jsonl"); + const daemon = await startFakeDaemon({ + createCommands, + failCreateSessionFiles: [failedSessionFile], + }); + cleanups.push(daemon.close); + + await expect( + restoreDaemonSessionSummaries(daemon.socketPath, [ + sessionSummary({ activeSessionId: "fail", sessionFile: failedSessionFile }), + sessionSummary({ activeSessionId: "ok", sessionFile: restoredSessionFile }), + ]), + ).resolves.toEqual({ + restored: 1, + total: 2, + failed: [{ sessionFile: failedSessionFile, error: "restore failed" }], + }); + expect(createCommands.map((command) => command.sessionPath)).toEqual([failedSessionFile, restoredSessionFile]); + }); +}); + describe("shutdownDaemonAndWait", () => { const cleanups: Array<() => Promise> = []; afterEach(async () => { diff --git a/packages/coding-agent/test/daemon-stop-confirm.test.ts b/packages/coding-agent/test/daemon-stop-confirm.test.ts index 425e10208c..b3289c3bdd 100644 --- a/packages/coding-agent/test/daemon-stop-confirm.test.ts +++ b/packages/coding-agent/test/daemon-stop-confirm.test.ts @@ -16,11 +16,21 @@ const COPY: DaemonSessionLossCopy = { function session(overrides: Partial): SessionSummary { return { + id: "session", + lifecycle: "live", + activity: "idle", + runtimeKind: "top-level", + activeSessionId: "active", + sessionId: "session", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp", isStreaming: false, isCompacting: false, + attachedClients: 0, + messageCount: 1, pendingMessageCount: 0, ...overrides, - } as unknown as SessionSummary; + }; } const ttyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); @@ -57,12 +67,22 @@ describe("confirmDaemonSessionLoss", () => { expect(await confirmDaemonSessionLoss(probe, { force: false, copy: COPY })).toBe(true); }); - it("aborts without prompting when not at a TTY and a live active session is idle", async () => { + it("proceeds without prompting when a live active session can be restored", async () => { setTTY(false); const probe: RunningDaemonProbe = { reachable: true, activeSessions: [session({ activeSessionId: "live-idle", isStreaming: false, pendingMessageCount: 0 })], }; + expect(await confirmDaemonSessionLoss(probe, { force: false, copy: COPY })).toBe(true); + expect(console.error).not.toHaveBeenCalled(); + }); + + it("aborts without prompting when not at a TTY and a live active session cannot be restored", async () => { + setTTY(false); + const probe: RunningDaemonProbe = { + reachable: true, + activeSessions: [session({ activeSessionId: "live-idle", runtimeKind: "subagent" })], + }; expect(await confirmDaemonSessionLoss(probe, { force: false, copy: COPY })).toBe(false); expect(console.error).toHaveBeenCalledWith(expect.stringContaining("hint")); expect(console.error).toHaveBeenCalledWith(expect.stringContaining("at-risk:1")); @@ -83,9 +103,14 @@ describe("confirmDaemonSessionLoss", () => { expect(console.error).toHaveBeenCalledWith(expect.stringContaining("unlistable")); }); - it("treats compacting, pending-message, and streaming sessions as at risk", async () => { + it("treats compacting, pending-message, streaming, and bash-running sessions as at risk", async () => { setTTY(false); - for (const overrides of [{ isCompacting: true }, { pendingMessageCount: 1 }, { isStreaming: true }]) { + for (const overrides of [ + { isCompacting: true }, + { pendingMessageCount: 1 }, + { isStreaming: true }, + { isBashRunning: true }, + ] satisfies Partial[]) { vi.mocked(console.error).mockClear(); const probe: RunningDaemonProbe = { reachable: true, activeSessions: [session(overrides)] }; expect(await confirmDaemonSessionLoss(probe, { force: false, copy: COPY })).toBe(false); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 2f6a5e3547..e1d566ed90 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -2,8 +2,45 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSyn import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type * as DaemonLaunchModule from "../src/cli/daemon-launch.js"; +import type { DaemonSessionRestoreResult, RunningDaemonProbe } from "../src/cli/daemon-launch.js"; import { APP_NAME, ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.js"; import { main } from "../src/main.js"; +import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js"; + +const daemonLaunchMock = vi.hoisted(() => ({ + probeRunningDaemonSessions: vi.fn<() => Promise>(), + relaunchDaemonAndRestoreSessions: + vi.fn<(socketPath: string, sessions: readonly SessionSummary[]) => Promise>(), +})); + +vi.mock("../src/cli/daemon-launch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + probeRunningDaemonSessions: daemonLaunchMock.probeRunningDaemonSessions, + relaunchDaemonAndRestoreSessions: daemonLaunchMock.relaunchDaemonAndRestoreSessions, + }; +}); + +function sessionSummary(overrides: Partial): SessionSummary { + return { + id: "session", + lifecycle: "live", + activity: "idle", + runtimeKind: "top-level", + activeSessionId: "active", + sessionId: "session", + sessionFile: "/tmp/session.jsonl", + cwd: "/tmp", + isStreaming: false, + isCompacting: false, + attachedClients: 0, + messageCount: 1, + pendingMessageCount: 0, + ...overrides, + }; +} function restoreEnv(name: string, value: string | undefined): void { if (value === undefined) { @@ -47,6 +84,10 @@ describe("package commands", () => { originalExecPath = process.execPath; process.exitCode = undefined; process.env[ENV_AGENT_DIR] = agentDir; + daemonLaunchMock.probeRunningDaemonSessions.mockReset(); + daemonLaunchMock.probeRunningDaemonSessions.mockResolvedValue({ reachable: false }); + daemonLaunchMock.relaunchDaemonAndRestoreSessions.mockReset(); + daemonLaunchMock.relaunchDaemonAndRestoreSessions.mockResolvedValue({ restored: 0, total: 0, failed: [] }); process.chdir(projectDir); }); @@ -188,6 +229,61 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); } }); + it("relaunches the daemon and restores previous open sessions after self-update", async () => { + const globalPrefix = join(tempDir, "global-prefix"); + const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@earendil-works", "pi-coding-agent"); + const fakeNpmPath = join(tempDir, "fake-npm.cjs"); + const recordPath = join(tempDir, "self-update.json"); + const restorableSession = sessionSummary({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: join(tempDir, "sessions", "session-1.jsonl"), + }); + mkdirSync(selfPackageDir, { recursive: true }); + writeFileSync( + fakeNpmPath, + `const fs=require("node:fs"),path=require("node:path"),args=process.argv.slice(2),prefix=args[args.indexOf("--prefix")+1]; +if(args.includes("root")) console.log(path.join(prefix,"lib","node_modules")); +else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); +`, + ); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", globalPrefix] }, null, 2), + ); + process.env.PI_PACKAGE_DIR = selfPackageDir; + Object.defineProperty(process, "execPath", { + value: join(selfPackageDir, "dist", "cli.js"), + configurable: true, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ version: getNewerPatchVersion() })), + ); + daemonLaunchMock.probeRunningDaemonSessions.mockResolvedValue({ + reachable: true, + activeSessions: [restorableSession], + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(main(["update", "--self"])).resolves.toBeUndefined(); + + expect(process.exitCode).toBeUndefined(); + expect(errorSpy).not.toHaveBeenCalled(); + expect(existsSync(recordPath)).toBe(true); + expect(daemonLaunchMock.relaunchDaemonAndRestoreSessions).toHaveBeenCalledOnce(); + expect(daemonLaunchMock.relaunchDaemonAndRestoreSessions).toHaveBeenCalledWith(expect.any(String), [ + restorableSession, + ]); + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + it("uses the current package name when the update check omits packageName", async () => { const globalPrefix = join(tempDir, "global-prefix"); const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent"); From 47c2962b19b18cb115261bc89c49f00ebc551ea1 Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 7 Jul 2026 13:08:35 -0700 Subject: [PATCH 04/14] fix(coding-agent): retry reopen busy update sessions --- packages/coding-agent/src/cli/daemon-launch.ts | 2 +- packages/coding-agent/test/daemon-launch.test.ts | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/cli/daemon-launch.ts b/packages/coding-agent/src/cli/daemon-launch.ts index c1ce4e5576..88ac47e124 100644 --- a/packages/coding-agent/src/cli/daemon-launch.ts +++ b/packages/coding-agent/src/cli/daemon-launch.ts @@ -175,7 +175,7 @@ function restoreCandidateSessionFiles(sessions: readonly SessionSummary[]): stri return [ ...new Set( sessions - .filter(isSessionRestorableAfterDaemonStop) + .filter(isSessionReopenableAfterDaemonStop) .map((session) => session.sessionFile) .filter((sessionFile): sessionFile is string => sessionFile !== undefined) .map((sessionFile) => resolve(sessionFile)), diff --git a/packages/coding-agent/test/daemon-launch.test.ts b/packages/coding-agent/test/daemon-launch.test.ts index 89e36edeaa..55c8336a6d 100644 --- a/packages/coding-agent/test/daemon-launch.test.ts +++ b/packages/coding-agent/test/daemon-launch.test.ts @@ -207,31 +207,37 @@ describe("restoreDaemonSessionSummaries", () => { await Promise.all(cleanups.splice(0).map((fn) => fn())); }); - it("reopens each distinct restorable session file", async () => { + it("reopens each distinct top-level session file", async () => { const createCommands: Array> = []; const daemon = await startFakeDaemon({ createCommands }); cleanups.push(daemon.close); const firstSessionFile = join(tmpdir(), "pa-session-restore-a.jsonl"); const secondSessionFile = join(tmpdir(), "pa-session-restore-b.jsonl"); + const busySessionFile = join(tmpdir(), "pa-session-restore-busy.jsonl"); await expect( restoreDaemonSessionSummaries(daemon.socketPath, [ sessionSummary({ activeSessionId: "a", sessionFile: firstSessionFile }), sessionSummary({ activeSessionId: "a-duplicate", sessionFile: firstSessionFile }), sessionSummary({ activeSessionId: "b", sessionFile: secondSessionFile }), - sessionSummary({ activeSessionId: "busy", sessionFile: join(tmpdir(), "busy.jsonl"), isStreaming: true }), + sessionSummary({ activeSessionId: "busy", sessionFile: busySessionFile, isStreaming: true }), sessionSummary({ activeSessionId: "sub", sessionFile: join(tmpdir(), "sub.jsonl"), runtimeKind: "subagent", }), ]), - ).resolves.toEqual({ restored: 2, total: 2, failed: [] }); + ).resolves.toEqual({ restored: 3, total: 3, failed: [] }); - expect(createCommands.map((command) => command.sessionPath)).toEqual([firstSessionFile, secondSessionFile]); + expect(createCommands.map((command) => command.sessionPath)).toEqual([ + firstSessionFile, + secondSessionFile, + busySessionFile, + ]); expect(createCommands.map((command) => command.config)).toEqual([ { sessionDir: dirname(firstSessionFile) }, { sessionDir: dirname(secondSessionFile) }, + { sessionDir: dirname(busySessionFile) }, ]); }); From f2c641e8c323dc06cf6dc606d1dda9a97abdb565 Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 7 Jul 2026 23:48:35 -0700 Subject: [PATCH 05/14] fix(coding-agent): guard daemon relaunch races --- .../coding-agent/src/cli/daemon-launch.ts | 3 ++ .../src/modes/daemon/daemon-mode.ts | 9 ++-- .../coding-agent/test/daemon-launch.test.ts | 15 +++++++ .../coding-agent/test/daemon-mode.test.ts | 45 +++++++++++++++++++ 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/cli/daemon-launch.ts b/packages/coding-agent/src/cli/daemon-launch.ts index e17ab5d49a..f242e63229 100644 --- a/packages/coding-agent/src/cli/daemon-launch.ts +++ b/packages/coding-agent/src/cli/daemon-launch.ts @@ -339,6 +339,9 @@ export async function relaunchDaemonAndRestoreSessions( options: { allowAtRiskSessions?: boolean } = {}, ): Promise { const latestProbe = await probeRunningDaemonSessions(socketPath); + if (latestProbe.reachable && latestProbe.activeSessions === undefined && !options.allowAtRiskSessions) { + throw new Error(`Cannot stop daemon on ${socketPath}: session list unavailable`); + } const sessionsToRestore = latestProbe.reachable ? (latestProbe.activeSessions ?? sessions) : sessions; const atRiskSessions = sessionsToRestore.filter(isSessionAtRiskFromDaemonStop); if (atRiskSessions.length > 0 && !options.allowAtRiskSessions) { diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index a94d004e87..3aa43166f0 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -377,7 +377,9 @@ export class AgentDaemon { activeSessionId?: string, ): Promise { const state: ActiveSessionState = { - activeSessionId: activeSessionId ?? createActiveSessionId(this.sessions), + activeSessionId: activeSessionId + ? createActiveSessionIdFromSeed(activeSessionId, this.sessions) + : createActiveSessionId(this.sessions), runtime, clients: new Set(), extensionUiRequests: new Map(), @@ -438,10 +440,7 @@ export class AgentDaemon { const sessionPath = command.sessionPath ? await resolveDaemonSessionPath(command.sessionPath, cwd, config.sessionDir) : undefined; - const restoredActiveSessionId = - command.activeSessionId && sessionPath - ? createActiveSessionIdFromSeed(command.activeSessionId, this.sessions) - : undefined; + const restoredActiveSessionId = command.activeSessionId && sessionPath ? command.activeSessionId : undefined; const sessionManager = sessionPath ? await SessionManager.openAsync(sessionPath, config.sessionDir, cwdOverride) : command.continueRecent diff --git a/packages/coding-agent/test/daemon-launch.test.ts b/packages/coding-agent/test/daemon-launch.test.ts index 7005f684fd..4dcc7148fd 100644 --- a/packages/coding-agent/test/daemon-launch.test.ts +++ b/packages/coding-agent/test/daemon-launch.test.ts @@ -7,6 +7,7 @@ import { isSessionAtRiskFromDaemonStop, isSessionRestorableAfterDaemonStop, probeRunningDaemonSessions, + relaunchDaemonAndRestoreSessions, restoreDaemonSessionSummaries, shutdownDaemonAndWait, } from "../src/cli/daemon-launch.js"; @@ -266,6 +267,20 @@ describe("restoreDaemonSessionSummaries", () => { }); }); +describe("relaunchDaemonAndRestoreSessions", () => { + const cleanups: Array<() => Promise> = []; + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((fn) => fn())); + }); + + it("refuses to stop a daemon when the latest session list is unavailable", async () => { + const daemon = await startFakeDaemon({ failList: true }); + cleanups.push(daemon.close); + + await expect(relaunchDaemonAndRestoreSessions(daemon.socketPath, [])).rejects.toThrow("session list unavailable"); + }); +}); + describe("shutdownDaemonAndWait", () => { const cleanups: Array<() => Promise> = []; afterEach(async () => { diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 4121a2c5fd..8544e53049 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -2197,6 +2197,51 @@ describe("daemon mode helpers", () => { } }); + it("deduplicates restored active session ids across different session files", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-restore-id-dupe-")); + try { + const firstSessionPath = join(tempDir, "first.jsonl"); + const secondSessionPath = join(tempDir, "second.jsonl"); + let releaseCreate: () => void = () => {}; + const createBarrier = new Promise((resolve) => { + releaseCreate = resolve; + }); + const createRuntime = vi.fn(async (options: Parameters[0]) => { + await createBarrier; + return { + session: makeRuntimeSession(options.sessionManager), + extensionsResult: { extensions: [], errors: [], runtime: {} } as unknown as Awaited< + ReturnType + >["extensionsResult"], + services: { cwd: options.cwd, agentDir: options.agentDir } as Awaited< + ReturnType + >["services"], + diagnostics: [], + }; + }); + const daemon = new AgentDaemon(join(tempDir, "daemon.sock"), { + defaultSessionConfig: { agentDir: tempDir, cwd: tempDir, sessionDir: tempDir }, + createRuntime, + }); + const create = ( + daemon as unknown as { + createRuntime(command: Extract): Promise; + } + ).createRuntime.bind(daemon); + + const first = create({ type: "create", activeSessionId: "same-active", sessionPath: firstSessionPath }); + const second = create({ type: "create", activeSessionId: "same-active", sessionPath: secondSessionPath }); + releaseCreate(); + const [firstState, secondState] = await Promise.all([first, second]); + + expect(firstState.activeSessionId).toBe("same-active"); + expect(secondState.activeSessionId).not.toBe("same-active"); + expect(secondState.activeSessionId).not.toBe(firstState.activeSessionId); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("adopts client env on session reuse only when the session has none", async () => { const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-env-")); try { From 01a4a6a1665e2399b3e02819d9392b4b324338f4 Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 7 Jul 2026 23:49:26 -0700 Subject: [PATCH 06/14] fix(coding-agent): refuse relaunch on unknown live sessions --- packages/coding-agent/src/cli/daemon-launch.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/cli/daemon-launch.ts b/packages/coding-agent/src/cli/daemon-launch.ts index f242e63229..663360dde7 100644 --- a/packages/coding-agent/src/cli/daemon-launch.ts +++ b/packages/coding-agent/src/cli/daemon-launch.ts @@ -339,8 +339,8 @@ export async function relaunchDaemonAndRestoreSessions( options: { allowAtRiskSessions?: boolean } = {}, ): Promise { const latestProbe = await probeRunningDaemonSessions(socketPath); - if (latestProbe.reachable && latestProbe.activeSessions === undefined && !options.allowAtRiskSessions) { - throw new Error(`Cannot stop daemon on ${socketPath}: session list unavailable`); + if (latestProbe.reachable && latestProbe.activeSessions === undefined) { + throw new Error(`Cannot stop daemon on ${socketPath}: live session list unavailable`); } const sessionsToRestore = latestProbe.reachable ? (latestProbe.activeSessions ?? sessions) : sessions; const atRiskSessions = sessionsToRestore.filter(isSessionAtRiskFromDaemonStop); From 20917a75a46f021a31f5d4910c463959758f74d6 Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 7 Jul 2026 23:52:57 -0700 Subject: [PATCH 07/14] fix(coding-agent): avoid repeated update prompts --- .../coding-agent/src/cli/daemon-launch.ts | 5 +--- .../coding-agent/src/package-manager-cli.ts | 26 ++++++++++++++++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/cli/daemon-launch.ts b/packages/coding-agent/src/cli/daemon-launch.ts index 663360dde7..4ad0da9d75 100644 --- a/packages/coding-agent/src/cli/daemon-launch.ts +++ b/packages/coding-agent/src/cli/daemon-launch.ts @@ -148,15 +148,12 @@ export interface DaemonSessionRestoreResult { export function hasSessionVolatileWorkForDaemonStop(summary: SessionSummary): boolean { // These states live in daemon memory and cannot be reconstructed by reopening - // the JSONL session file after the daemon restarts. Older daemons do not expose - // child-agent state separately, so an otherwise-unexplained working summary is - // treated as volatile rather than risking orphaned child work. + // the JSONL session file after the daemon restarts. return ( summary.isStreaming || summary.isCompacting || summary.isBashRunning === true || summary.hasRunningRlmChildren === true || - (summary.hasRunningRlmChildren === undefined && summary.activity === "working") || summary.pendingMessageCount > 0 ); } diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index eb02a438a1..70620652b6 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -4,6 +4,7 @@ import { selectConfig } from "./cli/config-selector.js"; import { type DaemonSessionRestoreResult, isRunningDaemonProbeAtRiskFromStop, + isSessionAtRiskFromDaemonStop, probeRunningDaemonSessions, type RunningDaemonProbe, relaunchDaemonAndRestoreSessions, @@ -387,6 +388,28 @@ function reportDaemonSessionRestoreWarnings(result: DaemonSessionRestoreResult): } } +function atRiskDaemonSessionKeys(probe: RunningDaemonProbe): Set | undefined { + if (!probe.reachable || probe.activeSessions === undefined) { + return undefined; + } + return new Set( + probe.activeSessions + .filter(isSessionAtRiskFromDaemonStop) + .map((session) => session.activeSessionId ?? session.sessionFile ?? session.sessionId), + ); +} + +function hasNewAtRiskDaemonSessions(before: RunningDaemonProbe, after: RunningDaemonProbe): boolean { + if (!after.reachable || after.activeSessions === undefined) { + return false; + } + const previousKeys = atRiskDaemonSessionKeys(before); + if (previousKeys === undefined) { + return false; + } + return [...atRiskDaemonSessionKeys(after)!].some((key) => !previousKeys.has(key)); +} + async function restartDaemonAfterSelfUpdate( socketPath: string, daemonProbe: RunningDaemonProbe, @@ -398,7 +421,8 @@ async function restartDaemonAfterSelfUpdate( try { const latestProbe = await probeRunningDaemonSessions(socketPath); const latestProbeAtRisk = isRunningDaemonProbeAtRiskFromStop(latestProbe); - if (latestProbeAtRisk && !(await confirmDaemonSessionLossBeforeUpdate(latestProbe, force))) { + const shouldPromptForLatestProbe = latestProbeAtRisk && hasNewAtRiskDaemonSessions(daemonProbe, latestProbe); + if (shouldPromptForLatestProbe && !(await confirmDaemonSessionLossBeforeUpdate(latestProbe, force))) { console.error( chalk.yellow("Warning: updated, but left the running daemon in place to avoid terminating live sessions."), ); From 55c8b52035837a2bd4417a1ad697dcd70c717e85 Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 7 Jul 2026 23:59:03 -0700 Subject: [PATCH 08/14] fix(coding-agent): preserve forced daemon update consent --- .../coding-agent/src/cli/daemon-launch.ts | 2 +- .../coding-agent/src/package-manager-cli.ts | 8 ++- .../test/package-command-paths.test.ts | 57 +++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/cli/daemon-launch.ts b/packages/coding-agent/src/cli/daemon-launch.ts index 4ad0da9d75..fe0f1c1a33 100644 --- a/packages/coding-agent/src/cli/daemon-launch.ts +++ b/packages/coding-agent/src/cli/daemon-launch.ts @@ -336,7 +336,7 @@ export async function relaunchDaemonAndRestoreSessions( options: { allowAtRiskSessions?: boolean } = {}, ): Promise { const latestProbe = await probeRunningDaemonSessions(socketPath); - if (latestProbe.reachable && latestProbe.activeSessions === undefined) { + if (latestProbe.reachable && latestProbe.activeSessions === undefined && !options.allowAtRiskSessions) { throw new Error(`Cannot stop daemon on ${socketPath}: live session list unavailable`); } const sessionsToRestore = latestProbe.reachable ? (latestProbe.activeSessions ?? sessions) : sessions; diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 70620652b6..b8114f4a1b 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -400,13 +400,16 @@ function atRiskDaemonSessionKeys(probe: RunningDaemonProbe): Set | undef } function hasNewAtRiskDaemonSessions(before: RunningDaemonProbe, after: RunningDaemonProbe): boolean { - if (!after.reachable || after.activeSessions === undefined) { + if (!after.reachable) { return false; } const previousKeys = atRiskDaemonSessionKeys(before); if (previousKeys === undefined) { return false; } + if (after.activeSessions === undefined) { + return true; + } return [...atRiskDaemonSessionKeys(after)!].some((key) => !previousKeys.has(key)); } @@ -420,6 +423,7 @@ async function restartDaemonAfterSelfUpdate( } try { const latestProbe = await probeRunningDaemonSessions(socketPath); + const initialProbeAtRisk = isRunningDaemonProbeAtRiskFromStop(daemonProbe); const latestProbeAtRisk = isRunningDaemonProbeAtRiskFromStop(latestProbe); const shouldPromptForLatestProbe = latestProbeAtRisk && hasNewAtRiskDaemonSessions(daemonProbe, latestProbe); if (shouldPromptForLatestProbe && !(await confirmDaemonSessionLossBeforeUpdate(latestProbe, force))) { @@ -432,7 +436,7 @@ async function restartDaemonAfterSelfUpdate( ? (latestProbe.activeSessions ?? daemonProbe.activeSessions ?? []) : (daemonProbe.activeSessions ?? []); const restoreResult = await relaunchDaemonAndRestoreSessions(socketPath, restoreSessions, undefined, { - allowAtRiskSessions: latestProbeAtRisk, + allowAtRiskSessions: initialProbeAtRisk || latestProbeAtRisk, }); reportDaemonSessionRestoreWarnings(restoreResult); } catch (error: unknown) { diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 77961cd9ec..10cc9a3832 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -305,6 +305,63 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); } }); + it("carries prior force consent when post-update probing cannot list sessions", async () => { + const globalPrefix = join(tempDir, "global-prefix"); + const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@earendil-works", "pi-coding-agent"); + const fakeNpmPath = join(tempDir, "fake-npm.cjs"); + const recordPath = join(tempDir, "self-update.json"); + const atRiskSession = sessionSummary({ + activeSessionId: "busy", + sessionId: "session-busy", + sessionFile: join(tempDir, "sessions", "busy.jsonl"), + isStreaming: true, + }); + mkdirSync(selfPackageDir, { recursive: true }); + writeFileSync( + fakeNpmPath, + `const fs=require("node:fs"),path=require("node:path"),args=process.argv.slice(2),prefix=args[args.indexOf("--prefix")+1]; +if(args.includes("root")) console.log(path.join(prefix,"lib","node_modules")); +else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); +`, + ); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", globalPrefix] }, null, 2), + ); + process.env.PI_PACKAGE_DIR = selfPackageDir; + Object.defineProperty(process, "execPath", { + value: join(selfPackageDir, "dist", "cli.js"), + configurable: true, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ version: getNewerPatchVersion() })), + ); + daemonLaunchMock.probeRunningDaemonSessions + .mockResolvedValueOnce({ reachable: true, activeSessions: [atRiskSession] }) + .mockResolvedValueOnce({ reachable: true }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(main(["update", "--self", "--force"])).resolves.toBeUndefined(); + + expect(process.exitCode).toBeUndefined(); + expect(errorSpy).not.toHaveBeenCalled(); + expect(existsSync(recordPath)).toBe(true); + expect(daemonLaunchMock.relaunchDaemonAndRestoreSessions).toHaveBeenCalledWith( + expect.any(String), + [atRiskSession], + undefined, + { allowAtRiskSessions: true }, + ); + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + it("uses the current package name when the update check omits packageName", async () => { const globalPrefix = join(tempDir, "global-prefix"); const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent"); From 3a727f706e729b53ee7019b3d505f9729baf4406 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 8 Jul 2026 00:05:16 -0700 Subject: [PATCH 09/14] fix(coding-agent): report automatic restore failures --- .../coding-agent/src/cli/daemon-launch.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/cli/daemon-launch.ts b/packages/coding-agent/src/cli/daemon-launch.ts index fe0f1c1a33..4db2b5a9a1 100644 --- a/packages/coding-agent/src/cli/daemon-launch.ts +++ b/packages/coding-agent/src/cli/daemon-launch.ts @@ -35,6 +35,20 @@ function logDaemonLaunch(message: string): void { appendRotatingLog(getClientErrorLogPath(), `[${new Date().toISOString()}] daemon-launch: ${message}`); } +function reportDaemonLaunchRestoreWarnings(result: DaemonSessionRestoreResult): void { + if (result.failed.length === 0) { + return; + } + const message = `Warning: restored ${result.restored}/${result.total} daemon session(s), but ${result.failed.length} session(s) failed to reopen after daemon replacement.`; + logDaemonLaunch(message); + console.error(message); + for (const failure of result.failed) { + const detail = ` ${failure.sessionFile}: ${failure.error}`; + logDaemonLaunch(detail); + console.error(detail); + } +} + async function canConnectToDaemon(socketPath: string, timeoutMs: number): Promise { const client = new DaemonClient(socketPath); try { @@ -376,12 +390,7 @@ async function ensureDaemonRunning(socketPath: string, spawnCwd?: string): Promi } const restoreResult = await spawnDaemonAndRestoreSessions(socketPath, restoreSessions, spawnCwd); - if (restoreResult.failed.length > 0) { - logDaemonLaunch( - `restored ${restoreResult.restored}/${restoreSessions.length} session(s) after daemon replacement; ` + - `${restoreResult.failed.length} failed`, - ); - } + reportDaemonLaunchRestoreWarnings(restoreResult); } const ensurePromises = new Map>(); From 21f94721d0e6ba7317015642df54731e67f30084 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 8 Jul 2026 00:07:31 -0700 Subject: [PATCH 10/14] fix(coding-agent): reuse takeover daemon probe --- packages/coding-agent/src/cli/daemon-launch.ts | 4 ++-- packages/coding-agent/src/main.ts | 2 +- packages/coding-agent/src/package-manager-cli.ts | 1 + packages/coding-agent/test/package-command-paths.test.ts | 9 ++++++--- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/cli/daemon-launch.ts b/packages/coding-agent/src/cli/daemon-launch.ts index 4db2b5a9a1..7609b7cd09 100644 --- a/packages/coding-agent/src/cli/daemon-launch.ts +++ b/packages/coding-agent/src/cli/daemon-launch.ts @@ -347,9 +347,9 @@ export async function relaunchDaemonAndRestoreSessions( socketPath: string, sessions: readonly SessionSummary[], spawnCwd?: string, - options: { allowAtRiskSessions?: boolean } = {}, + options: { allowAtRiskSessions?: boolean; latestProbe?: RunningDaemonProbe } = {}, ): Promise { - const latestProbe = await probeRunningDaemonSessions(socketPath); + const latestProbe = options.latestProbe ?? (await probeRunningDaemonSessions(socketPath)); if (latestProbe.reachable && latestProbe.activeSessions === undefined && !options.allowAtRiskSessions) { throw new Error(`Cannot stop daemon on ${socketPath}: live session list unavailable`); } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 60db12746a..22ae232444 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -420,7 +420,7 @@ async function takeOverStaleDaemonOrExit(socketPath: string): Promise ({ socketPath: string, sessions: readonly SessionSummary[], spawnCwd?: string, - options?: { allowAtRiskSessions?: boolean }, + options?: { allowAtRiskSessions?: boolean; latestProbe?: RunningDaemonProbe }, ) => Promise >(), })); @@ -297,7 +297,10 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); expect.any(String), [postUpdateSession], undefined, - { allowAtRiskSessions: false }, + { + allowAtRiskSessions: false, + latestProbe: { reachable: true, activeSessions: [postUpdateSession] }, + }, ); } finally { logSpy.mockRestore(); @@ -354,7 +357,7 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); expect.any(String), [atRiskSession], undefined, - { allowAtRiskSessions: true }, + { allowAtRiskSessions: true, latestProbe: { reachable: true } }, ); } finally { logSpy.mockRestore(); From 84baecb074aa2bd452eeffb8a8b0ec5685794044 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 8 Jul 2026 00:12:33 -0700 Subject: [PATCH 11/14] fix(coding-agent): recheck stale takeover sessions --- packages/coding-agent/src/main.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 22ae232444..1d510b6adc 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -416,11 +416,26 @@ async function takeOverStaleDaemonOrExit(socketPath: string): Promise Date: Wed, 8 Jul 2026 00:17:28 -0700 Subject: [PATCH 12/14] fix(coding-agent): re-prompt for new stale takeover risk --- packages/coding-agent/src/main.ts | 35 +++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 1d510b6adc..f72a8cce66 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -18,8 +18,10 @@ import { ensureInteractiveDaemonRunning, isDaemonSessionSummary, isRunningDaemonProbeAtRiskFromStop, + isSessionAtRiskFromDaemonStop, listActiveDaemonSessionSummaries, probeRunningDaemonSessions, + type RunningDaemonProbe, relaunchDaemonAndRestoreSessions, StaleDaemonError, } from "./cli/daemon-launch.js"; @@ -398,6 +400,31 @@ function reportDaemonSessionRestoreWarnings(result: DaemonSessionRestoreResult): } } +function atRiskDaemonSessionKeys(probe: RunningDaemonProbe): Set | undefined { + if (!probe.reachable || probe.activeSessions === undefined) { + return undefined; + } + return new Set( + probe.activeSessions + .filter(isSessionAtRiskFromDaemonStop) + .map((session) => session.activeSessionId ?? session.sessionFile ?? session.sessionId), + ); +} + +function hasNewAtRiskDaemonSessions(before: RunningDaemonProbe, after: RunningDaemonProbe): boolean { + if (!after.reachable) { + return false; + } + const previousKeys = atRiskDaemonSessionKeys(before); + if (previousKeys === undefined) { + return false; + } + if (after.activeSessions === undefined) { + return true; + } + return [...atRiskDaemonSessionKeys(after)!].some((key) => !previousKeys.has(key)); +} + // The promise to keep after awaiting readiness. Wrapped in an object so it // survives `await` (which would otherwise flatten a returned Promise to void). type DaemonReadyResult = { ready: Promise | undefined }; @@ -417,8 +444,12 @@ async function takeOverStaleDaemonOrExit(socketPath: string): Promise Date: Thu, 9 Jul 2026 12:39:34 -0700 Subject: [PATCH 13/14] fix(coding-agent): protect cron ownership on restored sessions --- packages/coding-agent/src/core/cron-jobs.ts | 5 ++- .../src/modes/daemon/daemon-mode.ts | 5 ++- .../coding-agent/src/package-manager-cli.ts | 2 +- packages/coding-agent/test/cron-jobs.test.ts | 44 +++++++++++++++++++ 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/core/cron-jobs.ts b/packages/coding-agent/src/core/cron-jobs.ts index f388b75627..696d5c3cca 100644 --- a/packages/coding-agent/src/core/cron-jobs.ts +++ b/packages/coding-agent/src/core/cron-jobs.ts @@ -149,11 +149,14 @@ export class AgentCronJobStore { sessionId: string; sessionFile: string; cwd: string; + matchActiveSessionId?: boolean; }): AgentCronJob[] { const targetSessionFile = resolve(input.sessionFile); const reboundJobs: AgentCronJob[] = []; const jobs = this.readJobs().map((job) => { - if (job.activeSessionId !== input.activeSessionId && resolve(job.sessionFile) !== targetSessionFile) { + const matchesActiveSessionId = + input.matchActiveSessionId !== false && job.activeSessionId === input.activeSessionId; + if (!matchesActiveSessionId && resolve(job.sessionFile) !== targetSessionFile) { return job; } if ( diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 3d3585dc51..68ee6f9eb6 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -434,7 +434,7 @@ export class AgentDaemon { } finally { this.bindingSessions.delete(state.activeSessionId); } - this.rebindCronJobsToState(state); + this.rebindCronJobsToState(state, { matchActiveSessionId: activeSessionId === undefined }); if (runtime.metadata.kind !== "subagent") { // Mark the session as daemon-resident so a restarted daemon can // restore it. Closes for kill/completed/replaced flip this back to @@ -834,7 +834,7 @@ export class AgentDaemon { return job; } - private rebindCronJobsToState(state: ActiveSessionState): void { + private rebindCronJobsToState(state: ActiveSessionState, options: { matchActiveSessionId?: boolean } = {}): void { const sessionFile = state.runtime.session.sessionFile; if (!sessionFile) { return; @@ -844,6 +844,7 @@ export class AgentDaemon { sessionId: state.runtime.session.sessionId, sessionFile, cwd: state.runtime.cwd, + matchActiveSessionId: options.matchActiveSessionId, }); if (reboundJobs.some((job) => job.status === "active")) { this.cronScheduler.wake(); diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index fc09109967..b07c0f2507 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -381,7 +381,7 @@ const UPDATE_RESTART_CONTINUATION_PROMPT = const UPDATE_SESSION_LOSS_COPY: DaemonSessionLossCopy = { atRiskDetail(count) { const { noun, pronoun } = pluralizeSessions(count); - return `The running daemon has ${count} busy ${noun}. After the update installs, Prime Agent will stop ${pronoun}, restart the daemon, and resume interrupted work.`; + return `The running daemon has ${count} active ${noun}. After the update installs, Prime Agent will stop ${pronoun}, restart the daemon, and resume interrupted work.`; }, unlistableDetail: "A running daemon's sessions could not be listed. After the update installs, Prime Agent will stop resident sessions, restart the daemon, and resume interrupted work where possible.", diff --git a/packages/coding-agent/test/cron-jobs.test.ts b/packages/coding-agent/test/cron-jobs.test.ts index 525e9b7413..4392a072f3 100644 --- a/packages/coding-agent/test/cron-jobs.test.ts +++ b/packages/coding-agent/test/cron-jobs.test.ts @@ -369,6 +369,50 @@ describe("AgentCronJobStore", () => { ); }); + it("does not move jobs by active session id when rebinding only by session file", () => { + const store = new AgentCronJobStore(makeStorePath(tempDirs)); + const otherSessionHeartbeat = store.createHeartbeat({ + activeSessionId: "active-1", + sessionId: "session-1", + sessionFile: "/tmp/session-1.jsonl", + cwd: "/tmp/project", + scheduleText: "every 5m", + prompt: "check on the first session", + now: start, + }); + const targetSessionHeartbeat = store.createHeartbeat({ + activeSessionId: "old-active-2", + sessionId: "session-2", + sessionFile: "/tmp/session-2.jsonl", + cwd: "/tmp/project", + scheduleText: "every 10m", + prompt: "check on the second session", + now: start, + }); + + const rebound = store.rebindSessionJobs({ + activeSessionId: "active-1", + sessionId: "session-2", + sessionFile: "/tmp/session-2.jsonl", + cwd: "/tmp/project-restored", + matchActiveSessionId: false, + }); + + expect(rebound.map((job) => job.id)).toEqual([targetSessionHeartbeat.id]); + expect(store.getHeartbeat("active-1")).toMatchObject({ + id: otherSessionHeartbeat.id, + sessionId: "session-1", + sessionFile: "/tmp/session-1.jsonl", + }); + expect(store.getHeartbeat("old-active-2")).toBeUndefined(); + expect(store.list().find((job) => job.id === targetSessionHeartbeat.id)).toMatchObject({ + activeSessionId: "active-1", + sessionId: "session-2", + sessionFile: "/tmp/session-2.jsonl", + cwd: "/tmp/project-restored", + }); + }); + it("keeps multiple RLM heartbeats separate from the single user heartbeat", () => { const store = new AgentCronJobStore(makeStorePath(tempDirs)); const userHeartbeat = store.createHeartbeat({ From bbbded556cfce564e8839986c9df4347eb3af722 Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 9 Jul 2026 18:26:19 -0700 Subject: [PATCH 14/14] fix(coding-agent): preserve stale takeover restore sessions --- .../coding-agent/src/cli/daemon-launch.ts | 1 + packages/coding-agent/src/main.ts | 23 ++++++++++++++----- .../coding-agent/test/daemon-launch.test.ts | 10 ++++---- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/src/cli/daemon-launch.ts b/packages/coding-agent/src/cli/daemon-launch.ts index eb855a3a64..08c320b962 100644 --- a/packages/coding-agent/src/cli/daemon-launch.ts +++ b/packages/coding-agent/src/cli/daemon-launch.ts @@ -239,6 +239,7 @@ export async function restoreDaemonSessionSummaries( sessionPath: sessionFile, config: { sessionDir: dirname(sessionFile), + ...(sourceSummary?.cwd ? { cwd: sourceSummary.cwd } : {}), }, }); if (response.success) { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 86f9ec0540..c4179bbc2b 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -443,6 +443,13 @@ async function takeOverStaleDaemonOrExit(socketPath: string): Promise { await expect( restoreDaemonSessionSummaries(daemon.socketPath, [ - sessionSummary({ activeSessionId: "a", sessionFile: firstSessionFile }), - sessionSummary({ activeSessionId: "a-duplicate", sessionFile: firstSessionFile }), - sessionSummary({ activeSessionId: "b", sessionFile: secondSessionFile }), + sessionSummary({ activeSessionId: "a", sessionFile: firstSessionFile, cwd: "/tmp/project-a" }), + sessionSummary({ activeSessionId: "a-duplicate", sessionFile: firstSessionFile, cwd: "/tmp/project-dupe" }), + sessionSummary({ activeSessionId: "b", sessionFile: secondSessionFile, cwd: "/tmp/project-b" }), sessionSummary({ activeSessionId: "busy", sessionFile: busySessionFile, isStreaming: true }), sessionSummary({ activeSessionId: "sub", @@ -238,8 +238,8 @@ describe("restoreDaemonSessionSummaries", () => { [secondSessionFile, "b"], ]); expect(createCommands.map((command) => command.config)).toEqual([ - { sessionDir: dirname(firstSessionFile) }, - { sessionDir: dirname(secondSessionFile) }, + { sessionDir: dirname(firstSessionFile), cwd: "/tmp/project-a" }, + { sessionDir: dirname(secondSessionFile), cwd: "/tmp/project-b" }, ]); });