Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4c9dd6a
Protect live sessions during daemon updates
sethkarten Jul 7, 2026
c1cfff2
Add Claude Sonnet 5 to Prime Inference
Jul 7, 2026
1f4a387
fix(coding-agent): restore daemon sessions after update
sethkarten Jul 7, 2026
47c2962
fix(coding-agent): retry reopen busy update sessions
sethkarten Jul 7, 2026
7a48e04
fix(coding-agent): preserve live sessions across daemon updates
sethkarten Jul 8, 2026
f2c641e
fix(coding-agent): guard daemon relaunch races
sethkarten Jul 8, 2026
01a4a6a
fix(coding-agent): refuse relaunch on unknown live sessions
sethkarten Jul 8, 2026
20917a7
fix(coding-agent): avoid repeated update prompts
sethkarten Jul 8, 2026
55c8b52
fix(coding-agent): preserve forced daemon update consent
sethkarten Jul 8, 2026
3a727f7
fix(coding-agent): report automatic restore failures
sethkarten Jul 8, 2026
21f9472
fix(coding-agent): reuse takeover daemon probe
sethkarten Jul 8, 2026
84baecb
fix(coding-agent): recheck stale takeover sessions
sethkarten Jul 8, 2026
da8d772
fix(coding-agent): re-prompt for new stale takeover risk
sethkarten Jul 8, 2026
ffaf388
Merge origin/main into daemon update branch
sethkarten Jul 9, 2026
80fd344
Merge remote-tracking branch 'origin/main' into HEAD
sethkarten Jul 9, 2026
3f7e8ba
fix(coding-agent): protect cron ownership on restored sessions
sethkarten Jul 9, 2026
0d951d0
Merge remote-tracking branch 'origin/main' into HEAD
sethkarten Jul 9, 2026
f68ebd8
merge main into graceful daemon update, fixes #333
kevinjosethomas Jul 10, 2026
bbbded5
fix(coding-agent): preserve stale takeover restore sessions
sethkarten Jul 10, 2026
e7d2517
Merge origin/main into daemon update branch
sethkarten Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

## [Unreleased]

- Changed automatic harness refinement to be enabled by default while keeping `autoRefine.enabled: false` as the opt-out.
- Changed the new-chat splash to show only version, model, and cwd metadata and rotate among five example prompts.
- Fixed daemon updates and stale-daemon takeovers preserving restorable live sessions while blocking volatile child-agent work.
- Fixed non-numeric `autoRefine.turnInterval` and `autoRefine.cooldownMs` settings falling back to defaults instead of silently enabling a noisy auto-refine loop.
- Fixed all session-resume entry points to share a searchable full-screen picker, stream results while loading, and support renaming ([ENG-4513](https://linear.app/primeintellect/issue/ENG-4513/resume-in-agents-view-is-broken)).

## [0.2.8] - 2026-07-09

Expand All @@ -14,9 +18,6 @@
- Fixed the agents-view splash shifting when opening an agent session ([ENG-4517](https://linear.app/primeintellect/issue/ENG-4517)).
- Changed `/model` to sort featured flagship models above a provider's long tail (with a numeric-aware alphabetical tiebreak), so the full Prime Inference catalog doesn't flood the picker.
- Fixed selector prompts and choices filling their background through the terminal's right edge.
- Changed automatic harness refinement to be enabled by default while keeping `autoRefine.enabled: false` as the opt-out.
- Fixed non-numeric `autoRefine.turnInterval` and `autoRefine.cooldownMs` settings falling back to defaults instead of silently enabling a noisy auto-refine loop.
- Fixed all session-resume entry points to share a searchable full-screen picker, stream results while loading, and support renaming ([ENG-4513](https://linear.app/primeintellect/issue/ENG-4513/resume-in-agents-view-is-broken)).

## [0.2.7] - 2026-07-08

Expand Down
220 changes: 187 additions & 33 deletions packages/coding-agent/src/cli/daemon-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<boolean> {
const client = new DaemonClient(socketPath);
try {
Expand Down Expand Up @@ -132,12 +146,23 @@ export async function shutdownDaemonAndWait(socketPath: string): Promise<boolean
}

// activeSessions is undefined when the daemon is reachable but its sessions couldn't
// be listed — callers must treat that as "possibly busy", not idle.
// be listed — callers must treat that as "possibly active", not idle.
export type RunningDaemonProbe = { reachable: false } | { reachable: true; activeSessions?: SessionSummary[] };

export function isSessionBusy(summary: SessionSummary): boolean {
// pendingMessageCount covers queued steering/follow-ups, which live only in
// memory and would be lost if the daemon were stopped.
export interface DaemonSessionRestoreFailure {
sessionFile: string;
error: string;
}

export interface DaemonSessionRestoreResult {
restored: number;
total: number;
failed: DaemonSessionRestoreFailure[];
}

export function hasSessionVolatileWorkForDaemonStop(summary: SessionSummary): boolean {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
// These states live in daemon memory and cannot be reconstructed by reopening
// the JSONL session file after the daemon restarts.
return (
summary.isStreaming ||
summary.isCompacting ||
Expand All @@ -147,6 +172,88 @@ export function isSessionBusy(summary: SessionSummary): boolean {
);
Comment thread
cursor[bot] marked this conversation as resolved.
}

export function isSessionReopenableAfterDaemonStop(summary: SessionSummary): boolean {
return (
summary.activeSessionId !== undefined && summary.runtimeKind !== "subagent" && summary.sessionFile !== undefined
);
}

export function isSessionRestorableAfterDaemonStop(summary: SessionSummary): boolean {
return isSessionReopenableAfterDaemonStop(summary) && !hasSessionVolatileWorkForDaemonStop(summary);
}

export function isSessionBusy(summary: SessionSummary): boolean {
return hasSessionVolatileWorkForDaemonStop(summary);
}

export function isSessionAtRiskFromDaemonStop(summary: SessionSummary): boolean {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
if (summary.activeSessionId === undefined) {
return hasSessionVolatileWorkForDaemonStop(summary);
}
return !isSessionRestorableAfterDaemonStop(summary);
}

export function isRunningDaemonProbeAtRiskFromStop(probe: RunningDaemonProbe): boolean {
if (!probe.reachable) {
return false;
}
return probe.activeSessions === undefined || probe.activeSessions.some(isSessionAtRiskFromDaemonStop);
}

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)),
),
];
Comment thread
cursor[bot] marked this conversation as resolved.
}

export async function restoreDaemonSessionSummaries(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium cli/daemon-launch.ts:215

restoreDaemonSessionSummaries recreates each session with a config object containing only sessionDir and cwd, dropping the per-session runtime settings (model/provider, thinkingLevel, tool/extension restrictions, etc.) from the original live session. createRuntime() fills missing fields from the daemon defaults, so after a stale-daemon takeover an idle live session can reopen with a different model and tool restrictions than it was running with before the restart. Consider forwarding the original session's runtime config fields into the create request so the reopened session preserves its settings.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/cli/daemon-launch.ts around line 215:

`restoreDaemonSessionSummaries` recreates each session with a `config` object containing only `sessionDir` and `cwd`, dropping the per-session runtime settings (model/provider, `thinkingLevel`, tool/extension restrictions, etc.) from the original live session. `createRuntime()` fills missing fields from the daemon defaults, so after a stale-daemon takeover an idle live session can reopen with a different model and tool restrictions than it was running with before the restart. Consider forwarding the original session's runtime config fields into the `create` request so the reopened session preserves its settings.

socketPath: string,
sessions: readonly SessionSummary[],
): Promise<DaemonSessionRestoreResult> {
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 sourceSummary = sessions.find(
(session) =>
isSessionRestorableAfterDaemonStop(session) &&
session.sessionFile &&
resolve(session.sessionFile) === sessionFile,
);
const response = await client.request({
type: "create",
...(sourceSummary?.activeSessionId ? { activeSessionId: sourceSummary.activeSessionId } : {}),
sessionPath: sessionFile,
config: {
sessionDir: dirname(sessionFile),
...(sourceSummary?.cwd ? { cwd: sourceSummary.cwd } : {}),
},
Comment thread
cursor[bot] marked this conversation as resolved.
});
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<RunningDaemonProbe> {
const client = new DaemonClient(socketPath);
try {
Expand All @@ -165,23 +272,25 @@ export async function probeRunningDaemonSessions(socketPath: string): Promise<Ru
}
}

// Idle-but-loaded sessions reload from disk on the fresh daemon, so only a busy
// session blocks replacing a stale daemon.
async function shutdownStaleDaemonIfNotBusy(socketPath: string): Promise<boolean> {
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<StaleDaemonShutdownResult> {
const client = new DaemonClient(socketPath);
let connected = false;
let hasBusySessions = false;
let loadedSessionCount = 0;
let summaries: SessionSummary[] | undefined;
try {
await client.connect(1000);
connected = true;
try {
const summaries = await listActiveDaemonSessionSummaries(client);
loadedSessionCount = summaries.length;
hasBusySessions = summaries.some(isSessionBusy);
summaries = await listActiveDaemonSessionSummaries(client);
} catch {
// Couldn't confirm idleness: treat as busy rather than risk interrupting work.
hasBusySessions = 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.
Expand All @@ -190,30 +299,25 @@ async function shutdownStaleDaemonIfNotBusy(socketPath: string): Promise<boolean
}

if (!connected) {
return waitForDaemonGone(socketPath);
return { stopped: await waitForDaemonGone(socketPath), restoreSessions: [] };
}
if (hasBusySessions) {
logDaemonLaunch(`refusing to replace stale daemon on ${socketPath}: busy session(s) present`);
return false;
if (!summaries) {
logDaemonLaunch(`refusing to replace stale daemon on ${socketPath}: session list unavailable`);
return { stopped: false, restoreSessions: [] };
}
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} (idle): ${loadedSessionCount} loaded session(s) will reload`,
`replacing stale daemon on ${socketPath}: ${restoreSessions.length} live session(s) will be reopened`,
);
return shutdownDaemonAndWait(socketPath);
return { stopped: await shutdownDaemonAndWait(socketPath), restoreSessions };
}

async function ensureDaemonRunning(socketPath: string, spawnCwd?: string): Promise<void> {
const probe = await probeDaemonVersion(socketPath);
if (probe === "current") {
return;
}
if (probe === "stale") {
const stopped = await shutdownStaleDaemonIfNotBusy(socketPath);
if (!stopped) {
throw new StaleDaemonError(socketPath);
}
}

async function spawnDaemonAndWait(socketPath: string, spawnCwd?: string): Promise<void> {
const entrypoint = process.argv[1];
if (!entrypoint) {
throw new Error("Cannot determine current CLI entrypoint for daemon launch");
Expand Down Expand Up @@ -244,6 +348,56 @@ 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,
options: { allowAtRiskSessions?: boolean; latestProbe?: RunningDaemonProbe } = {},
): Promise<DaemonSessionRestoreResult> {
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`);
Comment thread
cursor[bot] marked this conversation as resolved.
}
const sessionsToRestore = latestProbe.reachable ? (latestProbe.activeSessions ?? sessions) : sessions;
Comment thread
cursor[bot] marked this conversation as resolved.
const atRiskSessions = sessionsToRestore.filter(isSessionAtRiskFromDaemonStop);
if (atRiskSessions.length > 0 && !options.allowAtRiskSessions) {
throw new Error(`Cannot stop daemon on ${socketPath}: unrestorable live session(s) present`);
}
const stopped = await shutdownDaemonAndWait(socketPath);
if (!stopped) {
throw new Error(`Could not stop daemon on ${socketPath}`);
}
await spawnDaemonAndWait(socketPath, spawnCwd);
return restoreDaemonSessionSummaries(socketPath, sessionsToRestore);
}

export async function spawnDaemonAndRestoreSessions(
socketPath: string,
sessions: readonly SessionSummary[],
spawnCwd?: string,
): Promise<DaemonSessionRestoreResult> {
await spawnDaemonAndWait(socketPath, spawnCwd);
return restoreDaemonSessionSummaries(socketPath, sessions);
}

async function ensureDaemonRunning(socketPath: string, spawnCwd?: string): Promise<void> {
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);
reportDaemonLaunchRestoreWarnings(restoreResult);
}

const ensurePromises = new Map<string, Promise<void>>();

/**
Expand Down
25 changes: 13 additions & 12 deletions packages/coding-agent/src/cli/daemon-stop-confirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 busy sessions. They keep
* the same busy-session 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
Expand All @@ -12,7 +13,7 @@

import { createInterface } from "node:readline";
import chalk from "chalk";
import { isSessionBusy, type RunningDaemonProbe } from "./daemon-launch.js";
import { isSessionAtRiskFromDaemonStop, type RunningDaemonProbe } from "./daemon-launch.js";

/** Prompt for a yes/no answer at a TTY. Empty/anything-but-yes resolves false (default No). */
export function promptYesNo(message: string): Promise<boolean> {
Expand All @@ -31,8 +32,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 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;
/** Question appended after the detail when prompting at a TTY (before " [y/N]"). */
Expand All @@ -43,10 +44,10 @@ 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, running bash, or pending messages)
* require confirmation; idle loaded sessions are restored from disk.
* 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). Restorable top-level sessions are reopened after the
* fresh daemon starts.
*/
export async function confirmDaemonSessionLoss(
probe: RunningDaemonProbe,
Expand All @@ -61,11 +62,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}`));
Expand Down
5 changes: 4 additions & 1 deletion packages/coding-agent/src/core/cron-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Loading