From 4187aace5534feb9c0e61f721b1f444e705076ee Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Thu, 9 Jul 2026 17:36:29 -0700 Subject: [PATCH 01/14] fix(coding-agent): preserve daemon sessions across updates --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/cli/daemon-launch.ts | 13 +- .../daemon-agent-connection.ts | 95 +++++++++++++ .../src/modes/agents-view/agents-view-mode.ts | 57 +++++++- .../src/modes/daemon/daemon-client.ts | 20 +++ .../src/modes/daemon/daemon-mode.ts | 8 +- .../src/modes/daemon/daemon-socket.ts | 69 ++++++++- .../test/agent-connection-daemon.test.ts | 74 ++++++++++ .../coding-agent/test/daemon-client.test.ts | 19 +++ .../coding-agent/test/daemon-socket.test.ts | 134 +++++++++++++++++- 10 files changed, 479 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 02c7214d53..b6570d71e8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ - 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)). +- Fixed self-updates losing restored daemon sessions to a socket cleanup race and leaving open session or agents-view windows disconnected. ## [0.2.7] - 2026-07-08 diff --git a/packages/coding-agent/src/cli/daemon-launch.ts b/packages/coding-agent/src/cli/daemon-launch.ts index 394e477540..3ac764cbd6 100644 --- a/packages/coding-agent/src/cli/daemon-launch.ts +++ b/packages/coding-agent/src/cli/daemon-launch.ts @@ -107,10 +107,13 @@ export class StaleDaemonError extends Error { } } -async function waitForDaemonGone(socketPath: string, timeoutMs = 5000): Promise { +async function waitForDaemonGone(socketPath: string, timeoutMs = 5000, requireSocketCleanup = false): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - if (!(await canConnectToDaemon(socketPath, 250))) { + if ( + !(await canConnectToDaemon(socketPath, 250)) && + (!requireSocketCleanup || process.platform === "win32" || !existsSync(socketPath)) + ) { return true; } await delay(25); @@ -120,15 +123,17 @@ async function waitForDaemonGone(socketPath: string, timeoutMs = 5000): Promise< export async function shutdownDaemonAndWait(socketPath: string): Promise { const client = new DaemonClient(socketPath); + let shutdownAccepted = false; try { await client.connect(1000); - await client.request({ type: "shutdown" }).catch(() => undefined); + const response = await client.request({ type: "shutdown" }); + shutdownAccepted = response.success; } catch { // A connect failure isn't treated as "gone"; waitForDaemonGone is the source of truth. } finally { client.close(); } - return waitForDaemonGone(socketPath); + return waitForDaemonGone(socketPath, 5000, shutdownAccepted); } // activeSessions is undefined when the daemon is reachable but its sessions couldn't diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index 450ed9c697..85f145f819 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -60,6 +60,12 @@ type DistributiveOmit = T extends unknown ? Omit : n type DaemonCommandBody = DistributiveOmit; export const DAEMON_REFINE_REQUEST_TIMEOUT_MS = 10 * 60 * 1000; +const UPDATE_RECONNECT_TIMEOUT_MS = 120000; +const UPDATE_RECONNECT_RETRY_MS = 100; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} export interface DaemonAgentConnectionOptions { closeClientOnDispose?: boolean; @@ -86,6 +92,11 @@ export class DaemonAgentConnection implements AgentConnection { private lastEventSequence: number | undefined; private latestSnapshot: AgentConnectionSnapshot | undefined; private latestSnapshotIsFresh = false; + private attachedSessionId: string | undefined; + private attachedSessionFile: string | undefined; + private updateRestartPending = false; + private updateReconnectPromise?: Promise; + private disposed = false; constructor( private readonly client: DaemonClient, @@ -96,6 +107,10 @@ export class DaemonAgentConnection implements AgentConnection { void this.handleDaemonMessage(message); }); this.unsubscribeDaemonClose = this.client.onClose((error) => { + if (this.updateRestartPending && !this.disposed) { + void this.reconnectAfterUpdate(); + return; + } void this.emit({ type: "closed", error: error.message }); }); } @@ -132,6 +147,9 @@ export class DaemonAgentConnection implements AgentConnection { }, }); this.activeSessionId = getAttachActiveSessionId(result); + const summary = "snapshot" in result ? result.snapshot.summary : result; + this.attachedSessionId = summary.sessionId; + this.attachedSessionFile = summary.sessionFile; this.lastEventSequence = maxEventSequence(this.lastEventSequence, getAttachLastEventSequence(result)); if ("snapshot" in result) { this.latestSnapshot = mapDaemonAttachSnapshot(result); @@ -683,6 +701,8 @@ export class DaemonAgentConnection implements AgentConnection { } async dispose(): Promise { + this.disposed = true; + this.updateRestartPending = false; this.unsubscribeDaemonMessages(); this.unsubscribeDaemonClose(); await this.requestOk({ type: "detach", activeSessionId: this.activeSessionId }).catch(() => undefined); @@ -756,10 +776,78 @@ export class DaemonAgentConnection implements AgentConnection { return; } if (message.type === "session_closed") { + if (message.reason === "update") { + this.updateRestartPending = true; + return; + } await this.emit({ type: "closed", error: message.reason }); } } + private reconnectAfterUpdate(): Promise { + if (this.updateReconnectPromise) { + return this.updateReconnectPromise; + } + const reconnectPromise = this.restoreConnectionAfterUpdate() + .catch(async (error: unknown) => { + if (!this.disposed) { + await this.emit({ + type: "closed", + error: `Failed to reconnect after update: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }) + .finally(() => { + if (this.updateReconnectPromise === reconnectPromise) { + this.updateReconnectPromise = undefined; + } + }); + this.updateReconnectPromise = reconnectPromise; + return reconnectPromise; + } + + private async restoreConnectionAfterUpdate(): Promise { + const sessionId = this.attachedSessionId; + const sessionFile = this.attachedSessionFile; + if (!sessionId && !sessionFile) { + throw new Error("the previous session identity is unavailable"); + } + const deadline = Date.now() + UPDATE_RECONNECT_TIMEOUT_MS; + let lastError: unknown; + while (!this.disposed && Date.now() < deadline) { + try { + await this.client.reconnect(1000); + const response = await this.client.request({ type: "list" }, 30000); + if (!response.success) { + throw deserializeDaemonError(response); + } + const sessions = readSessionSummaries(response.data); + const restored = sessions.find( + (summary) => + summary.activeSessionId !== undefined && + ((sessionFile !== undefined && summary.sessionFile === sessionFile) || + (sessionId !== undefined && summary.sessionId === sessionId)), + ); + if (restored?.activeSessionId) { + this.activeSessionId = restored.activeSessionId; + this.lastEventSequence = undefined; + await this.attach(); + const snapshot = await this.getInitialSnapshot(); + this.updateRestartPending = false; + await this.emit({ type: "session_replaced", state: snapshot.state, messages: snapshot.messages }); + return; + } + } catch (error) { + lastError = error; + } + await delay(UPDATE_RECONNECT_RETRY_MS); + } + if (this.disposed) { + return; + } + throw lastError ?? new Error("the restored session did not become available"); + } + private isMessageForActiveSession(message: DaemonOutbound): boolean { if (!("activeSessionId" in message)) { return false; @@ -788,6 +876,13 @@ export class DaemonAgentConnection implements AgentConnection { } } +function readSessionSummaries(value: unknown): SessionSummary[] { + if (!value || typeof value !== "object" || !Array.isArray((value as { sessions?: unknown }).sessions)) { + throw new Error("Daemon returned an invalid session list response"); + } + return (value as { sessions: SessionSummary[] }).sessions; +} + function getAttachActiveSessionId(result: SessionSummary | DaemonAttachResult): string { if ("snapshot" in result) { return result.activeSessionId; diff --git a/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts b/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts index 947faf8bda..611918f0a3 100644 --- a/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts +++ b/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts @@ -81,6 +81,8 @@ import { } from "./agents-view-state.js"; const POLL_INTERVAL_MS = 1000; +const RECONNECT_TIMEOUT_MS = 120000; +const RECONNECT_RETRY_MS = 100; const EXIT_HINT_DURATION_MS = 2000; const DELETE_CONFIRM_DURATION_MS = 2000; const STATUS_MESSAGE_DURATION_MS = 4500; @@ -376,6 +378,8 @@ class AgentsViewMode implements Component, Focusable { private readonly fullscreenDock: Component; private readonly keybindings: KeybindingsManager; private client: DaemonClient | undefined; + private unsubscribeClientClose: (() => void) | undefined; + private reconnectPromise: Promise | undefined; private resolveRun: ((result: AgentsViewRunResult) => void) | undefined; private pollTimer: NodeJS.Timeout | undefined; private animationTimer: NodeJS.Timeout | undefined; @@ -470,6 +474,7 @@ class AgentsViewMode implements Component, Focusable { async run(): Promise { this.client = new DaemonClient(this.options.socketPath); await this.client.connect(); + this.subscribeToClientClose(this.client); this.ui.addChild(this); this.ui.setFocus(this); @@ -1725,6 +1730,9 @@ class AgentsViewMode implements Component, Focusable { } private async refreshSessions(): Promise { + if (this.reconnectPromise) { + return; + } const client = this.requireClient(); try { const response = await client.request(createAgentsViewListCommand()); @@ -1744,7 +1752,9 @@ class AgentsViewMode implements Component, Focusable { this.restoreSelection(); this.ui.requestRender(); } catch (error) { - this.setStatusMessage(formatError("Failed to refresh agents", error)); + if (!this.reconnectPromise) { + this.setStatusMessage(formatError("Failed to refresh agents", error)); + } } } @@ -1824,12 +1834,57 @@ class AgentsViewMode implements Component, Focusable { flushFullscreen: false, }); stopThemeWatcher(); + this.unsubscribeClientClose?.(); + this.unsubscribeClientClose = undefined; this.client?.close(); this.client = undefined; this.resolveRun?.(result); this.resolveRun = undefined; } + private subscribeToClientClose(client: DaemonClient): void { + this.unsubscribeClientClose?.(); + this.unsubscribeClientClose = client.onClose((error) => { + if (this.stopped || client !== this.client || this.reconnectPromise) { + return; + } + this.setStatusMessage("Daemon restarted; reconnecting...", { sticky: true }); + const reconnectPromise = this.reconnectClient(client, error).finally(() => { + if (this.reconnectPromise === reconnectPromise) { + this.reconnectPromise = undefined; + } + }); + this.reconnectPromise = reconnectPromise; + }); + } + + private async reconnectClient(client: DaemonClient, closeError: Error): Promise { + const deadline = Date.now() + RECONNECT_TIMEOUT_MS; + let lastError: unknown = closeError; + while (!this.stopped && client === this.client && Date.now() < deadline) { + try { + await client.reconnect(1000); + const response = await client.request(createAgentsViewListCommand()); + const data = requireDaemonData(response); + const sessions = expectSessionList(data); + this.lastListedSummaries = sessions; + this.reconnectPromise = undefined; + this.setStatusMessage("Reconnected after daemon restart", { render: false }); + await this.refreshSessions(); + return; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, RECONNECT_RETRY_MS)); + } + if (!this.stopped && client === this.client) { + this.setStatusMessage(formatError("Failed to reconnect to daemon", lastError), { + tone: "error", + sticky: true, + }); + } + } + private requireClient(): DaemonClient { if (!this.client) { throw new Error("Agents view daemon client is not connected"); diff --git a/packages/coding-agent/src/modes/daemon/daemon-client.ts b/packages/coding-agent/src/modes/daemon/daemon-client.ts index 8cb20461fa..fdb97c2dce 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-client.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-client.ts @@ -37,6 +37,7 @@ export class DaemonClient { >(); private requestId = 0; private helloMessage?: DaemonHello; + private reconnectPromise?: Promise; private readonly helloWaiters = new Set<{ resolve: (hello: DaemonHello) => void; reject: (error: Error) => void; @@ -75,6 +76,7 @@ export class DaemonClient { throw new Error("Daemon client is already connected"); } + this.helloMessage = undefined; const socket = createConnection(this.socketPath); this.socket = socket; this.detachReader = attachJsonlLineReader(socket, (line) => this.handleLine(line)); @@ -108,6 +110,24 @@ export class DaemonClient { socket.on("close", () => this.notifyClosed(socket, new Error("Daemon socket closed"))); } + async reconnect(timeoutMs = 3000): Promise { + if (this.reconnectPromise) { + return this.reconnectPromise; + } + if (this.socket && !this.socket.destroyed) { + return; + } + const reconnectPromise = this.connect(timeoutMs); + this.reconnectPromise = reconnectPromise; + try { + await reconnectPromise; + } finally { + if (this.reconnectPromise === reconnectPromise) { + this.reconnectPromise = undefined; + } + } + } + onMessage(listener: DaemonClientMessageListener): () => void { this.listeners.add(listener); return () => { diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 150b25c443..09385bd6f3 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -124,7 +124,9 @@ import { import { DaemonSessionSummarizer } from "./daemon-session-summarizer.js"; import { cleanupDaemonSocketPath, + type DaemonSocketIdentity, defaultDaemonSocketPath, + getDaemonSocketIdentity, prepareDaemonSocketPath, restrictDaemonSocketPath, } from "./daemon-socket.js"; @@ -248,6 +250,7 @@ export class AgentDaemon { private shuttingDown = false; private updateRestartPreparing = false; private ownsSocketPath = false; + private socketIdentity?: DaemonSocketIdentity; private readonly clients = new Set(); private readonly sessions = new Map(); private readonly openingSessions = new Map>(); @@ -337,6 +340,7 @@ export class AgentDaemon { const onListening = () => { this.server?.off("error", onError); try { + this.socketIdentity = getDaemonSocketIdentity(this.socketPath); this.ownsSocketPath = true; if (process.platform !== "win32") { restrictDaemonSocketPath(this.socketPath); @@ -371,7 +375,9 @@ export class AgentDaemon { return; } this.ownsSocketPath = false; - cleanupDaemonSocketPath(this.socketPath); + const socketIdentity = this.socketIdentity; + this.socketIdentity = undefined; + cleanupDaemonSocketPath(this.socketPath, socketIdentity); } /** diff --git a/packages/coding-agent/src/modes/daemon/daemon-socket.ts b/packages/coding-agent/src/modes/daemon/daemon-socket.ts index 745f994561..5da432f226 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-socket.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-socket.ts @@ -5,6 +5,13 @@ import { dirname, join } from "node:path"; const DAEMON_SOCKET_MODE = 0o600; const DAEMON_SOCKET_DIR_MODE = 0o700; +const DAEMON_SOCKET_RELEASE_GRACE_MS = 1000; +const DAEMON_SOCKET_RELEASE_POLL_MS = 25; + +export interface DaemonSocketIdentity { + dev: number; + ino: number; +} export function defaultDaemonSocketPath(): string { if (process.platform === "win32") { @@ -20,14 +27,45 @@ export async function prepareDaemonSocketPath(socketPath: string): Promise return; } - const stat = lstatSync(socketPath); + let stat: ReturnType; + try { + stat = lstatSync(socketPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw error; + } if (!stat.isSocket()) { throw new Error(`Daemon socket path exists and is not a socket: ${socketPath}`); } + const staleIdentity: DaemonSocketIdentity = { dev: stat.dev, ino: stat.ino }; if (await canConnectToUnixSocket(socketPath)) { throw new Error(`Daemon socket already in use: ${socketPath}`); } + const deadline = Date.now() + DAEMON_SOCKET_RELEASE_GRACE_MS; + while (Date.now() < deadline) { + await delay(DAEMON_SOCKET_RELEASE_POLL_MS); + if (!existsSync(socketPath)) { + return; + } + let currentIdentity: DaemonSocketIdentity | undefined; + try { + currentIdentity = getDaemonSocketIdentity(socketPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw error; + } + if (!currentIdentity || currentIdentity.dev !== staleIdentity.dev || currentIdentity.ino !== staleIdentity.ino) { + throw new Error(`Daemon socket changed ownership while waiting for cleanup: ${socketPath}`); + } + if (await canConnectToUnixSocket(socketPath)) { + throw new Error(`Daemon socket already in use: ${socketPath}`); + } + } unlinkSync(socketPath); } @@ -39,14 +77,33 @@ export function restrictDaemonSocketPath(socketPath: string): void { chmodSync(socketPath, DAEMON_SOCKET_MODE); } -export function cleanupDaemonSocketPath(socketPath: string): void { +export function getDaemonSocketIdentity(socketPath: string): DaemonSocketIdentity | undefined { + if (process.platform === "win32") { + return undefined; + } + const stat = lstatSync(socketPath); + return { dev: stat.dev, ino: stat.ino }; +} + +export function cleanupDaemonSocketPath(socketPath: string, expectedIdentity?: DaemonSocketIdentity): void { if (process.platform === "win32") { return; } try { - if (existsSync(socketPath)) { - unlinkSync(socketPath); + if (!existsSync(socketPath)) { + return; } + if (expectedIdentity) { + const currentIdentity = getDaemonSocketIdentity(socketPath); + if ( + !currentIdentity || + currentIdentity.dev !== expectedIdentity.dev || + currentIdentity.ino !== expectedIdentity.ino + ) { + return; + } + } + unlinkSync(socketPath); } catch { // Best effort cleanup; shutdown should not be blocked by socket unlink failures. } @@ -102,3 +159,7 @@ function canConnectToUnixSocket(socketPath: string): Promise { socket.once("error", () => finish(false)); }); } + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/coding-agent/test/agent-connection-daemon.test.ts b/packages/coding-agent/test/agent-connection-daemon.test.ts index be17b3eee9..804e142b1e 100644 --- a/packages/coding-agent/test/agent-connection-daemon.test.ts +++ b/packages/coding-agent/test/agent-connection-daemon.test.ts @@ -31,8 +31,10 @@ class FakeDaemonClient { readonly requestTimeouts: number[] = []; attachResultFactory: ((command: Extract) => DaemonAttachResult) | undefined; closeCount = 0; + reconnectCount = 0; abortBashUnknownCommand = false; abortAndClearQueueUnknownCommand = false; + updateRestartSessions: Array> = []; private readonly messageListeners = new Set(); private readonly closeListeners = new Set(); @@ -44,6 +46,13 @@ class FakeDaemonClient { this.requests.push(command); this.requestTimeouts.push(timeoutMs); switch (command.type) { + case "list": + return { + type: "response", + command: command.type, + success: true, + data: { sessions: this.updateRestartSessions }, + }; case "attach": if (command.activeSessionId === "missing") { return { @@ -356,6 +365,16 @@ class FakeDaemonClient { } } + emitClose(error: Error): void { + for (const listener of [...this.closeListeners]) { + listener(error); + } + } + + async reconnect(): Promise { + this.reconnectCount++; + } + getMessageListenerCount(): number { return this.messageListeners.size; } @@ -503,6 +522,61 @@ function emitSequencedQueueUpdate(client: FakeDaemonClient, activeSessionId: str } describe("DaemonAgentConnection", () => { + it("reattaches an open window to its restored session after an update restart", async () => { + const fakeClient = new FakeDaemonClient(); + const restoredMessages: AgentMessage[] = [{ role: "user", content: "restored prompt", timestamp: 2 }]; + fakeClient.updateRestartSessions = [ + { + id: "active-restored", + activeSessionId: "active-restored", + sessionId: "session-current", + sessionFile: "/tmp/session-current.jsonl", + }, + ]; + fakeClient.attachResultFactory = (command) => + createAttachResult(command.activeSessionId, command.clientId, command.capabilities, 1, { + state: createConnectionState(command.activeSessionId, "session-current"), + messages: command.activeSessionId === "active-restored" ? restoredMessages : [], + }); + const connection = new DaemonAgentConnection(asDaemonClient(fakeClient), "active-original"); + const events: AgentConnectionEvent[] = []; + const restored = new Promise((resolve) => { + connection.subscribe((event) => { + events.push(event); + if (event.type === "session_replaced") { + resolve(event); + } + }); + }); + await connection.attach(); + + fakeClient.emitMessage({ + type: "session_closed", + activeSessionId: "active-original", + reason: "update", + }); + fakeClient.emitClose(new Error("Daemon socket closed")); + + await expect(restored).resolves.toMatchObject({ + type: "session_replaced", + state: { activeSessionId: "active-restored", sessionId: "session-current" }, + messages: restoredMessages, + }); + expect(fakeClient.reconnectCount).toBe(1); + expect(fakeClient.requests.map((request) => request.type)).toEqual(["attach", "list", "attach"]); + expect(fakeClient.requests.at(-1)).toMatchObject({ + type: "attach", + activeSessionId: "active-restored", + resumeCursor: undefined, + }); + expect(events).toEqual([ + expect.objectContaining({ + type: "session_replaced", + state: expect.objectContaining({ activeSessionId: "active-restored" }), + }), + ]); + }); + it("loads connection state and forwards replacement snapshots through the daemon protocol", async () => { const fakeClient = new FakeDaemonClient(); const connection = new DaemonAgentConnection(asDaemonClient(fakeClient), "active-1"); diff --git a/packages/coding-agent/test/daemon-client.test.ts b/packages/coding-agent/test/daemon-client.test.ts index c715bc03d7..2032ed5599 100644 --- a/packages/coding-agent/test/daemon-client.test.ts +++ b/packages/coding-agent/test/daemon-client.test.ts @@ -434,6 +434,25 @@ describe("DaemonClient", () => { client.close(); }); + + it("shares one reconnect attempt across concurrent callers", async () => { + const client = new DaemonClient("/tmp/prime-agent.sock"); + + const firstConnect = client.connect(); + const firstSocket = netMock.sockets[0]!; + firstSocket.emit("connect"); + await firstConnect; + firstSocket.emit("close"); + + const reconnectA = client.reconnect(); + const reconnectB = client.reconnect(); + expect(netMock.sockets).toHaveLength(2); + const secondSocket = netMock.sockets[1]!; + secondSocket.emit("connect"); + + await expect(Promise.all([reconnectA, reconnectB])).resolves.toEqual([undefined, undefined]); + client.close(); + }); }); async function captureRejection(promise: Promise): Promise { diff --git a/packages/coding-agent/test/daemon-socket.test.ts b/packages/coding-agent/test/daemon-socket.test.ts index cbb8cbeb09..f04e615329 100644 --- a/packages/coding-agent/test/daemon-socket.test.ts +++ b/packages/coding-agent/test/daemon-socket.test.ts @@ -1,7 +1,15 @@ +import { spawn } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync, unlinkSync } from "node:fs"; +import { createConnection, createServer } from "node:net"; import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; -import { defaultDaemonSocketPath } from "../src/modes/daemon/daemon-socket.js"; +import { + cleanupDaemonSocketPath, + defaultDaemonSocketPath, + getDaemonSocketIdentity, + prepareDaemonSocketPath, +} from "../src/modes/daemon/daemon-socket.js"; describe("defaultDaemonSocketPath", () => { it("uses a fixed Windows named pipe path", () => { @@ -23,4 +31,128 @@ describe("defaultDaemonSocketPath", () => { expect(dirname(socketPath)).toBe(join(tmpdir(), `prime-agent-${suffix}`)); expect(basename(socketPath)).toBe("daemon.sock"); }); + + it("does not unlink a replacement daemon's socket during delayed cleanup", async () => { + if (process.platform === "win32") { + return; + } + + const dir = mkdtempSync(join(tmpdir(), "pa-socket-ownership-")); + const socketPath = join(dir, "daemon.sock"); + const oldServer = createServer(); + const replacementServer = createServer(); + try { + await new Promise((resolve, reject) => { + oldServer.once("error", reject); + oldServer.listen(socketPath, resolve); + }); + const oldIdentity = getDaemonSocketIdentity(socketPath); + if (!oldIdentity) { + throw new Error("Expected a Unix daemon socket identity"); + } + + unlinkSync(socketPath); + await new Promise((resolve, reject) => { + replacementServer.once("error", reject); + replacementServer.listen(socketPath, resolve); + }); + + cleanupDaemonSocketPath(socketPath, oldIdentity); + + await expect( + new Promise((resolve, reject) => { + const client = createConnection(socketPath); + client.once("connect", () => { + client.destroy(); + resolve(); + }); + client.once("error", reject); + }), + ).resolves.toBeUndefined(); + } finally { + await Promise.all( + [oldServer, replacementServer].map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + }), + ), + ); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("does not remove a replacement socket that appears while stale cleanup is pending", async () => { + if (process.platform === "win32") { + return; + } + + const dir = mkdtempSync(join(tmpdir(), "pa-socket-startup-")); + const socketPath = join(dir, "daemon.sock"); + const staleOwner = spawn( + process.execPath, + [ + "-e", + "const { createServer } = require('node:net'); const server = createServer(); server.listen(process.argv[1], () => process.stdout.write('ready'));", + socketPath, + ], + { stdio: ["ignore", "pipe", "ignore"] }, + ); + const replacementServer = createServer(); + let replacementTimer: ReturnType | undefined; + try { + await new Promise((resolve, reject) => { + staleOwner.stdout?.once("data", () => resolve()); + staleOwner.once("error", reject); + staleOwner.once("exit", (code) => { + if (code !== null && code !== 0) { + reject(new Error(`Stale socket owner exited before listening: ${code}`)); + } + }); + }); + staleOwner.kill("SIGKILL"); + await new Promise((resolve) => staleOwner.once("close", () => resolve())); + expect(existsSync(socketPath)).toBe(true); + + const replacementListening = new Promise((resolve, reject) => { + replacementTimer = setTimeout(() => { + try { + if (existsSync(socketPath)) { + unlinkSync(socketPath); + } + replacementServer.once("error", reject); + replacementServer.listen(socketPath, resolve); + } catch (error) { + reject(error); + } + }, 50); + }); + + await expect(prepareDaemonSocketPath(socketPath)).rejects.toThrow( + /socket (already in use|changed ownership)/i, + ); + await replacementListening; + await expect( + new Promise((resolve, reject) => { + const client = createConnection(socketPath); + client.once("connect", () => { + client.destroy(); + resolve(); + }); + client.once("error", reject); + }), + ).resolves.toBeUndefined(); + } finally { + if (replacementTimer) { + clearTimeout(replacementTimer); + } + if (staleOwner.exitCode === null && staleOwner.signalCode === null) { + staleOwner.kill("SIGKILL"); + } + if (replacementServer.listening) { + await new Promise((resolve) => replacementServer.close(() => resolve())); + } + rmSync(dir, { recursive: true, force: true }); + } + }); }); From 009c33719ad68f942bacf57b9aeefeca89e18a3e Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Thu, 9 Jul 2026 17:48:31 -0700 Subject: [PATCH 02/14] fix(coding-agent): address update restart review feedback --- .../coding-agent/src/cli/daemon-launch.ts | 9 ++- .../src/modes/agents-view/agents-view-mode.ts | 58 ++++++++---------- .../src/modes/daemon/daemon-socket.ts | 44 +++++++++++++- .../coding-agent/test/daemon-launch.test.ts | 59 ++++++++++++++++++- .../coding-agent/test/daemon-socket.test.ts | 40 +++++++++++++ 5 files changed, 172 insertions(+), 38 deletions(-) diff --git a/packages/coding-agent/src/cli/daemon-launch.ts b/packages/coding-agent/src/cli/daemon-launch.ts index 3ac764cbd6..31cdbed1c1 100644 --- a/packages/coding-agent/src/cli/daemon-launch.ts +++ b/packages/coding-agent/src/cli/daemon-launch.ts @@ -118,10 +118,13 @@ async function waitForDaemonGone(socketPath: string, timeoutMs = 5000, requireSo } await delay(25); } - return false; + // A daemon can exit without removing its Unix socket (for example, after a crash + // during shutdown). Once the cleanup grace has elapsed, a non-listening socket + // is safe for the replacement daemon's guarded startup path to reclaim. + return requireSocketCleanup && !(await canConnectToDaemon(socketPath, 250)); } -export async function shutdownDaemonAndWait(socketPath: string): Promise { +export async function shutdownDaemonAndWait(socketPath: string, timeoutMs = 5000): Promise { const client = new DaemonClient(socketPath); let shutdownAccepted = false; try { @@ -133,7 +136,7 @@ export async function shutdownDaemonAndWait(socketPath: string): Promise - shouldShowAgentsViewSession(summary, this.inactiveAgentIdentities.has(getSummaryIdentity(summary))), - ); - this.lastVisibleSummaries = this.withPendingDeleteSession(visibleSessions); - this.rows = buildAgentsViewRows( - this.lastVisibleSummaries, - this.expandedSubagentParents, - this.programShownParents, - ); - this.applyPendingAncestorExpansion(); - this.restoreSelection(); - this.ui.requestRender(); + this.applySessionList(expectSessionList(data)); } catch (error) { if (!this.reconnectPromise) { this.setStatusMessage(formatError("Failed to refresh agents", error)); @@ -1758,6 +1744,22 @@ class AgentsViewMode implements Component, Focusable { } } + private applySessionList(sessions: SessionSummary[]): void { + this.lastListedSummaries = sessions; + const visibleSessions = sessions.filter((summary) => + shouldShowAgentsViewSession(summary, this.inactiveAgentIdentities.has(getSummaryIdentity(summary))), + ); + this.lastVisibleSummaries = this.withPendingDeleteSession(visibleSessions); + this.rows = buildAgentsViewRows( + this.lastVisibleSummaries, + this.expandedSubagentParents, + this.programShownParents, + ); + this.applyPendingAncestorExpansion(); + this.restoreSelection(); + this.ui.requestRender(); + } + private withPendingDeleteSession(sessions: readonly SessionSummary[]): SessionSummary[] { const pending = this.pendingDeleteAgent; if (!pending) { @@ -1844,12 +1846,12 @@ class AgentsViewMode implements Component, Focusable { private subscribeToClientClose(client: DaemonClient): void { this.unsubscribeClientClose?.(); - this.unsubscribeClientClose = client.onClose((error) => { + this.unsubscribeClientClose = client.onClose(() => { if (this.stopped || client !== this.client || this.reconnectPromise) { return; } this.setStatusMessage("Daemon restarted; reconnecting...", { sticky: true }); - const reconnectPromise = this.reconnectClient(client, error).finally(() => { + const reconnectPromise = this.reconnectClient(client).finally(() => { if (this.reconnectPromise === reconnectPromise) { this.reconnectPromise = undefined; } @@ -1858,31 +1860,21 @@ class AgentsViewMode implements Component, Focusable { }); } - private async reconnectClient(client: DaemonClient, closeError: Error): Promise { - const deadline = Date.now() + RECONNECT_TIMEOUT_MS; - let lastError: unknown = closeError; - while (!this.stopped && client === this.client && Date.now() < deadline) { + private async reconnectClient(client: DaemonClient): Promise { + while (!this.stopped && client === this.client) { try { await client.reconnect(1000); const response = await client.request(createAgentsViewListCommand()); const data = requireDaemonData(response); const sessions = expectSessionList(data); - this.lastListedSummaries = sessions; - this.reconnectPromise = undefined; this.setStatusMessage("Reconnected after daemon restart", { render: false }); - await this.refreshSessions(); + this.applySessionList(sessions); return; - } catch (error) { - lastError = error; + } catch { + // Keep the existing rows visible and retry until the view exits or the daemon returns. } await new Promise((resolve) => setTimeout(resolve, RECONNECT_RETRY_MS)); } - if (!this.stopped && client === this.client) { - this.setStatusMessage(formatError("Failed to reconnect to daemon", lastError), { - tone: "error", - sticky: true, - }); - } } private requireClient(): DaemonClient { diff --git a/packages/coding-agent/src/modes/daemon/daemon-socket.ts b/packages/coding-agent/src/modes/daemon/daemon-socket.ts index 5da432f226..ad3c595e4e 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-socket.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-socket.ts @@ -2,11 +2,14 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, unlinkSync } from "node:fs import { createConnection } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import lockfile from "proper-lockfile"; const DAEMON_SOCKET_MODE = 0o600; const DAEMON_SOCKET_DIR_MODE = 0o700; const DAEMON_SOCKET_RELEASE_GRACE_MS = 1000; const DAEMON_SOCKET_RELEASE_POLL_MS = 25; +const DAEMON_SOCKET_LOCK_STALE_MS = 10000; +const DAEMON_SOCKET_LOCK_UPDATE_MS = 2000; export interface DaemonSocketIdentity { dev: number; @@ -23,7 +26,29 @@ export function defaultDaemonSocketPath(): string { export async function prepareDaemonSocketPath(socketPath: string): Promise { ensureDefaultDaemonSocketDir(socketPath); - if (process.platform === "win32" || !existsSync(socketPath)) { + if (process.platform === "win32") { + return; + } + const releaseLock = await lockfile.lock(socketPath, { + realpath: false, + stale: DAEMON_SOCKET_LOCK_STALE_MS, + update: DAEMON_SOCKET_LOCK_UPDATE_MS, + retries: { + retries: 600, + factor: 1, + minTimeout: DAEMON_SOCKET_RELEASE_POLL_MS, + maxTimeout: DAEMON_SOCKET_RELEASE_POLL_MS, + }, + }); + try { + await prepareUnixDaemonSocketPath(socketPath); + } finally { + await releaseLock(); + } +} + +async function prepareUnixDaemonSocketPath(socketPath: string): Promise { + if (!existsSync(socketPath)) { return; } @@ -89,6 +114,17 @@ export function cleanupDaemonSocketPath(socketPath: string, expectedIdentity?: D if (process.platform === "win32") { return; } + let releaseLock: (() => void) | undefined; + try { + releaseLock = lockfile.lockSync(socketPath, { + realpath: false, + stale: DAEMON_SOCKET_LOCK_STALE_MS, + update: DAEMON_SOCKET_LOCK_UPDATE_MS, + retries: 0, + }); + } catch { + return; + } try { if (!existsSync(socketPath)) { return; @@ -106,6 +142,12 @@ export function cleanupDaemonSocketPath(socketPath: string, expectedIdentity?: D unlinkSync(socketPath); } catch { // Best effort cleanup; shutdown should not be blocked by socket unlink failures. + } finally { + try { + releaseLock(); + } catch { + // Best effort cleanup; a failed release is recoverable as a stale lock. + } } } diff --git a/packages/coding-agent/test/daemon-launch.test.ts b/packages/coding-agent/test/daemon-launch.test.ts index d7c9628a71..9b448d8fa0 100644 --- a/packages/coding-agent/test/daemon-launch.test.ts +++ b/packages/coding-agent/test/daemon-launch.test.ts @@ -1,4 +1,5 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { createServer, type Server, type Socket } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -71,6 +72,51 @@ async function startFakeDaemon(options: FakeDaemonOptions = {}): Promise { + const dir = mkdtempSync(join(tmpdir(), "pa-launch-crash-")); + const socketPath = join(dir, "d.sock"); + const child = spawn( + process.execPath, + [ + "-e", + `const { createServer } = require("node:net"); +const socketPath = process.argv[1]; +const send = (socket, message) => socket.write(JSON.stringify(message) + "\\n"); +const server = createServer((socket) => { + send(socket, { type: "daemon_hello", socketPath, protocol: { name: "prime-agent-daemon", version: 1 } }); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\\n"); + if (newline === -1) return; + const command = JSON.parse(buffer.slice(0, newline)); + if (command.type !== "shutdown") return; + send(socket, { type: "response", command: "shutdown", id: command.id, success: true }); + socket.end(() => process.kill(process.pid, "SIGKILL")); + }); +}); +server.listen(socketPath, () => process.stdout.write("ready\\n"));`, + socketPath, + ], + { stdio: ["ignore", "pipe", "ignore"] }, + ); + await new Promise((resolve, reject) => { + child.stdout?.once("data", () => resolve()); + child.once("error", reject); + child.once("exit", (code, signal) => reject(new Error(`Daemon exited before listening: ${code ?? signal}`))); + }); + return { + socketPath, + close: async () => { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + await new Promise((resolve) => child.once("close", () => resolve())); + } + rmSync(dir, { recursive: true, force: true }); + }, + }; +} + describe("probeRunningDaemonSessions", () => { const cleanups: Array<() => Promise> = []; afterEach(async () => { @@ -127,4 +173,15 @@ describe("shutdownDaemonAndWait", () => { cleanups.push(daemon.close); expect(await shutdownDaemonAndWait(daemon.socketPath)).toBe(true); }); + + it("accepts an exited daemon that leaves a stale socket behind", async () => { + if (process.platform === "win32") { + return; + } + + const daemon = await startCrashingDaemon(); + cleanups.push(daemon.close); + expect(await shutdownDaemonAndWait(daemon.socketPath, 100)).toBe(true); + expect(existsSync(daemon.socketPath)).toBe(true); + }); }); diff --git a/packages/coding-agent/test/daemon-socket.test.ts b/packages/coding-agent/test/daemon-socket.test.ts index f04e615329..8f5ad69467 100644 --- a/packages/coding-agent/test/daemon-socket.test.ts +++ b/packages/coding-agent/test/daemon-socket.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, rmSync, unlinkSync } from "node:fs"; import { createConnection, createServer } from "node:net"; import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; +import lockfile from "proper-lockfile"; import { describe, expect, it } from "vitest"; import { cleanupDaemonSocketPath, @@ -82,6 +83,45 @@ describe("defaultDaemonSocketPath", () => { } }); + it("does not unlink a socket while another daemon owns the path lock", async () => { + if (process.platform === "win32") { + return; + } + + const dir = mkdtempSync(join(tmpdir(), "pa-socket-lock-")); + const socketPath = join(dir, "daemon.sock"); + const server = createServer(); + let releaseLock: (() => Promise) | undefined; + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + const identity = getDaemonSocketIdentity(socketPath); + if (!identity) { + throw new Error("Expected a Unix daemon socket identity"); + } + releaseLock = await lockfile.lock(socketPath, { realpath: false }); + + cleanupDaemonSocketPath(socketPath, identity); + + await expect( + new Promise((resolve, reject) => { + const client = createConnection(socketPath); + client.once("connect", () => { + client.destroy(); + resolve(); + }); + client.once("error", reject); + }), + ).resolves.toBeUndefined(); + } finally { + await releaseLock?.(); + await new Promise((resolve) => server.close(() => resolve())); + rmSync(dir, { recursive: true, force: true }); + } + }); + it("does not remove a replacement socket that appears while stale cleanup is pending", async () => { if (process.platform === "win32") { return; From c6dc4f00ee0fe65ec65a75d0c3a960c80c38f609 Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Thu, 9 Jul 2026 17:54:31 -0700 Subject: [PATCH 03/14] fix(coding-agent): bound agents view reconnect cycles --- .../src/modes/agents-view/agents-view-mode.ts | 48 +++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts b/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts index 1e1caefc96..178ca674ba 100644 --- a/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts +++ b/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts @@ -81,6 +81,7 @@ import { } from "./agents-view-state.js"; const POLL_INTERVAL_MS = 1000; +const RECONNECT_TIMEOUT_MS = 120000; const RECONNECT_RETRY_MS = 1000; const EXIT_HINT_DURATION_MS = 2000; const DELETE_CONFIRM_DURATION_MS = 2000; @@ -379,6 +380,7 @@ class AgentsViewMode implements Component, Focusable { private client: DaemonClient | undefined; private unsubscribeClientClose: (() => void) | undefined; private reconnectPromise: Promise | undefined; + private reconnectTimedOut = false; private resolveRun: ((result: AgentsViewRunResult) => void) | undefined; private pollTimer: NodeJS.Timeout | undefined; private animationTimer: NodeJS.Timeout | undefined; @@ -1739,7 +1741,7 @@ class AgentsViewMode implements Component, Focusable { this.applySessionList(expectSessionList(data)); } catch (error) { if (!this.reconnectPromise) { - this.setStatusMessage(formatError("Failed to refresh agents", error)); + this.startClientReconnect(client, error); } } } @@ -1846,35 +1848,51 @@ class AgentsViewMode implements Component, Focusable { private subscribeToClientClose(client: DaemonClient): void { this.unsubscribeClientClose?.(); - this.unsubscribeClientClose = client.onClose(() => { - if (this.stopped || client !== this.client || this.reconnectPromise) { - return; - } + this.unsubscribeClientClose = client.onClose((error) => this.startClientReconnect(client, error)); + } + + private startClientReconnect(client: DaemonClient, error: unknown): void { + if (this.stopped || client !== this.client || this.reconnectPromise) { + return; + } + if (!this.reconnectTimedOut) { this.setStatusMessage("Daemon restarted; reconnecting...", { sticky: true }); - const reconnectPromise = this.reconnectClient(client).finally(() => { - if (this.reconnectPromise === reconnectPromise) { - this.reconnectPromise = undefined; - } - }); - this.reconnectPromise = reconnectPromise; + } + const reconnectPromise = this.reconnectClient(client, error).finally(() => { + if (this.reconnectPromise === reconnectPromise) { + this.reconnectPromise = undefined; + } }); + this.reconnectPromise = reconnectPromise; } - private async reconnectClient(client: DaemonClient): Promise { - while (!this.stopped && client === this.client) { + private async reconnectClient(client: DaemonClient, initialError: unknown): Promise { + const deadline = Date.now() + RECONNECT_TIMEOUT_MS; + let lastError = initialError; + while (!this.stopped && client === this.client && Date.now() < deadline) { try { await client.reconnect(1000); const response = await client.request(createAgentsViewListCommand()); const data = requireDaemonData(response); const sessions = expectSessionList(data); + this.reconnectTimedOut = false; this.setStatusMessage("Reconnected after daemon restart", { render: false }); this.applySessionList(sessions); return; - } catch { - // Keep the existing rows visible and retry until the view exits or the daemon returns. + } catch (error) { + lastError = error; } await new Promise((resolve) => setTimeout(resolve, RECONNECT_RETRY_MS)); } + if (!this.stopped && client === this.client) { + this.reconnectTimedOut = true; + this.setStatusMessage(formatError("Daemon unavailable; retrying", lastError), { + tone: "error", + sticky: true, + render: false, + }); + this.applySessionList([]); + } } private requireClient(): DaemonClient { From 95b1514e47bfdf000df9a8c085ba34e676c880a5 Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Thu, 9 Jul 2026 17:58:48 -0700 Subject: [PATCH 04/14] fix(coding-agent): harden daemon reconnect edge cases --- .../src/modes/agents-view/agents-view-mode.ts | 11 +++- .../src/modes/daemon/daemon-client.ts | 4 ++ .../src/modes/daemon/daemon-socket.ts | 10 +++- .../coding-agent/test/daemon-client.test.ts | 3 ++ .../coding-agent/test/daemon-socket.test.ts | 52 ++++++++++++++++++- 5 files changed, 75 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts b/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts index 178ca674ba..7e087f52c4 100644 --- a/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts +++ b/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts @@ -1741,7 +1741,11 @@ class AgentsViewMode implements Component, Focusable { this.applySessionList(expectSessionList(data)); } catch (error) { if (!this.reconnectPromise) { - this.startClientReconnect(client, error); + if (client.isConnected) { + this.setStatusMessage(formatError("Failed to refresh agents", error)); + } else { + this.startClientReconnect(client, error); + } } } } @@ -1882,7 +1886,10 @@ class AgentsViewMode implements Component, Focusable { } catch (error) { lastError = error; } - await new Promise((resolve) => setTimeout(resolve, RECONNECT_RETRY_MS)); + await new Promise((resolve) => { + const retryTimer = setTimeout(resolve, RECONNECT_RETRY_MS); + retryTimer.unref?.(); + }); } if (!this.stopped && client === this.client) { this.reconnectTimedOut = true; diff --git a/packages/coding-agent/src/modes/daemon/daemon-client.ts b/packages/coding-agent/src/modes/daemon/daemon-client.ts index fdb97c2dce..0c6cdf0ba4 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-client.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-client.ts @@ -50,6 +50,10 @@ export class DaemonClient { return this.helloMessage; } + get isConnected(): boolean { + return this.socket !== undefined && !this.socket.destroyed; + } + /** Wait for the daemon_hello greeting sent on connect. */ async waitForHello(timeoutMs = 3000): Promise { if (this.helloMessage) { diff --git a/packages/coding-agent/src/modes/daemon/daemon-socket.ts b/packages/coding-agent/src/modes/daemon/daemon-socket.ts index ad3c595e4e..8a79157295 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-socket.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-socket.ts @@ -8,8 +8,8 @@ const DAEMON_SOCKET_MODE = 0o600; const DAEMON_SOCKET_DIR_MODE = 0o700; const DAEMON_SOCKET_RELEASE_GRACE_MS = 1000; const DAEMON_SOCKET_RELEASE_POLL_MS = 25; -const DAEMON_SOCKET_LOCK_STALE_MS = 10000; -const DAEMON_SOCKET_LOCK_UPDATE_MS = 2000; +const DAEMON_SOCKET_LOCK_STALE_MS = 5000; +const DAEMON_SOCKET_LOCK_UPDATE_MS = 1000; export interface DaemonSocketIdentity { dev: number; @@ -29,6 +29,12 @@ export async function prepareDaemonSocketPath(socketPath: string): Promise if (process.platform === "win32") { return; } + if (!existsSync(socketPath)) { + return; + } + if (await canConnectToUnixSocket(socketPath)) { + throw new Error(`Daemon socket already in use: ${socketPath}`); + } const releaseLock = await lockfile.lock(socketPath, { realpath: false, stale: DAEMON_SOCKET_LOCK_STALE_MS, diff --git a/packages/coding-agent/test/daemon-client.test.ts b/packages/coding-agent/test/daemon-client.test.ts index 2032ed5599..c62181a0ff 100644 --- a/packages/coding-agent/test/daemon-client.test.ts +++ b/packages/coding-agent/test/daemon-client.test.ts @@ -377,12 +377,14 @@ describe("DaemonClient", () => { it("notifies listeners when a connected daemon socket closes", async () => { const client = new DaemonClient("/tmp/prime-agent.sock"); + expect(client.isConnected).toBe(false); const connect = client.connect(); expect(netMock.sockets).toHaveLength(1); const socket = netMock.sockets[0]!; socket.emit("connect"); await connect; + expect(client.isConnected).toBe(true); const closed: Error[] = []; const unsubscribe = client.onClose((error) => closed.push(error)); @@ -390,6 +392,7 @@ describe("DaemonClient", () => { socket.emit("close"); expect(closed.map((error) => error.message)).toEqual(["Daemon socket closed"]); + expect(client.isConnected).toBe(false); unsubscribe(); client.close(); }); diff --git a/packages/coding-agent/test/daemon-socket.test.ts b/packages/coding-agent/test/daemon-socket.test.ts index 8f5ad69467..e1c8e220d3 100644 --- a/packages/coding-agent/test/daemon-socket.test.ts +++ b/packages/coding-agent/test/daemon-socket.test.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { existsSync, mkdtempSync, rmSync, unlinkSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, unlinkSync } from "node:fs"; import { createConnection, createServer } from "node:net"; import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; @@ -33,6 +33,56 @@ describe("defaultDaemonSocketPath", () => { expect(basename(socketPath)).toBe("daemon.sock"); }); + it("checks a live daemon before acquiring the socket path lock", async () => { + if (process.platform === "win32") { + return; + } + + const dir = mkdtempSync(join(tmpdir(), "pa-socket-live-")); + const socketPath = join(dir, "daemon.sock"); + let observedLock: boolean | undefined; + const server = createServer((socket) => { + observedLock = existsSync(`${socketPath}.lock`); + socket.destroy(); + }); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + + await expect(prepareDaemonSocketPath(socketPath)).rejects.toThrow(/socket already in use/i); + expect(observedLock).toBe(false); + } finally { + if (server.listening) { + await new Promise((resolve) => server.close(() => resolve())); + } + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("does not wait on a stale lock when no socket path exists", async () => { + if (process.platform === "win32") { + return; + } + + const dir = mkdtempSync(join(tmpdir(), "pa-socket-stale-lock-")); + const socketPath = join(dir, "daemon.sock"); + mkdirSync(`${socketPath}.lock`); + let prepared = false; + try { + await Promise.race([ + prepareDaemonSocketPath(socketPath).then(() => { + prepared = true; + }), + new Promise((resolve) => setTimeout(resolve, 250)), + ]); + expect(prepared).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("does not unlink a replacement daemon's socket during delayed cleanup", async () => { if (process.platform === "win32") { return; From 4ee91d344c033dcd4599712d292bf902d16e6eab Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Thu, 9 Jul 2026 18:10:51 -0700 Subject: [PATCH 05/14] fix(coding-agent): refresh restored session identity --- packages/ai/scripts/generate-models.ts | 20 +++++++ .../daemon-agent-connection.ts | 2 + .../test/agent-connection-daemon.test.ts | 53 +++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index e3e7e0d313..cf976738d9 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -427,6 +427,25 @@ function mergePrimeInferenceModels( return Array.from(models.values()); } +function refreshPrimeInferenceAliasLimits( + snapshotModels: Model<"openai-completions">[], + catalogModels: Model<"openai-completions">[], +): Model<"openai-completions">[] { + const liveModels = new Map(catalogModels.map((model) => [model.id.toLowerCase(), model])); + return snapshotModels.map((model) => { + const canonicalId = PRIME_INFERENCE_OPENROUTER_ALIASES[model.id.toLowerCase()]; + const canonical = canonicalId ? liveModels.get(canonicalId) : undefined; + if (!canonical) { + return model; + } + return { + ...model, + contextWindow: canonical.contextWindow, + maxTokens: canonical.maxTokens, + }; + }); +} + function includesCatalogCapability(value: unknown, capabilities: readonly string[]): boolean { if (!Array.isArray(value)) { return false; @@ -633,6 +652,7 @@ async function fetchPrimeInferenceModels(): Promise[ (model) => liveIds.has(model.id.toLowerCase()) || model.id.toLowerCase().startsWith("internal/"), ); } + snapshotModels = refreshPrimeInferenceAliasLimits(snapshotModels, catalogModels); const models = mergePrimeInferenceModels(snapshotModels, catalogModels); console.log(`Loaded ${models.length} Prime Inference models (${catalogModels.length} from the live catalog)`); return models; diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index 85f145f819..eaddf63491 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -752,6 +752,8 @@ export class DaemonAgentConnection implements AgentConnection { return; } if (message.type === "session_replaced") { + this.attachedSessionId = message.state.sessionId; + this.attachedSessionFile = message.state.sessionFile; const latestSnapshot: AgentConnectionSnapshot = { state: message.state, messages: message.messages, diff --git a/packages/coding-agent/test/agent-connection-daemon.test.ts b/packages/coding-agent/test/agent-connection-daemon.test.ts index 804e142b1e..98781e9e19 100644 --- a/packages/coding-agent/test/agent-connection-daemon.test.ts +++ b/packages/coding-agent/test/agent-connection-daemon.test.ts @@ -577,6 +577,59 @@ describe("DaemonAgentConnection", () => { ]); }); + it("reattaches using the replacement session identity after a session switch", async () => { + const fakeClient = new FakeDaemonClient(); + const restoredMessages: AgentMessage[] = [{ role: "user", content: "switched prompt", timestamp: 3 }]; + fakeClient.updateRestartSessions = [ + { + id: "active-restored", + activeSessionId: "active-restored", + sessionId: "session-next", + sessionFile: "/tmp/session-next.jsonl", + }, + ]; + fakeClient.attachResultFactory = (command) => { + const sessionId = command.activeSessionId === "active-restored" ? "session-next" : "session-current"; + return createAttachResult(command.activeSessionId, command.clientId, command.capabilities, 1, { + state: createConnectionState(command.activeSessionId, sessionId), + messages: command.activeSessionId === "active-restored" ? restoredMessages : [], + }); + }; + const connection = new DaemonAgentConnection(asDaemonClient(fakeClient), "active-original"); + await connection.attach(); + fakeClient.emitMessage({ + type: "session_replaced", + activeSessionId: "active-original", + state: createConnectionState("active-original", "session-next"), + messages: [{ role: "user", content: "switched prompt", timestamp: 2 }], + }); + + const restored = new Promise((resolve) => { + connection.subscribe((event) => { + if (event.type === "session_replaced") { + resolve(event); + } + }); + }); + fakeClient.emitMessage({ + type: "session_closed", + activeSessionId: "active-original", + reason: "update", + }); + fakeClient.emitClose(new Error("Daemon socket closed")); + + await expect(restored).resolves.toMatchObject({ + type: "session_replaced", + state: { activeSessionId: "active-restored", sessionId: "session-next" }, + messages: restoredMessages, + }); + expect(fakeClient.requests.map((request) => request.type)).toEqual(["attach", "list", "attach"]); + expect(fakeClient.requests.at(-1)).toMatchObject({ + type: "attach", + activeSessionId: "active-restored", + }); + }); + it("loads connection state and forwards replacement snapshots through the daemon protocol", async () => { const fakeClient = new FakeDaemonClient(); const connection = new DaemonAgentConnection(asDaemonClient(fakeClient), "active-1"); From ec97e2d77ea992eb8f2069a378f6dfe685b1f829 Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Thu, 9 Jul 2026 18:16:37 -0700 Subject: [PATCH 06/14] fix(coding-agent): start update reconnect immediately --- .../daemon-agent-connection.ts | 2 + .../test/agent-connection-daemon.test.ts | 46 ++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index eaddf63491..b2c1e9b9b4 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -780,6 +780,7 @@ export class DaemonAgentConnection implements AgentConnection { if (message.type === "session_closed") { if (message.reason === "update") { this.updateRestartPending = true; + void this.reconnectAfterUpdate(); return; } await this.emit({ type: "closed", error: message.reason }); @@ -792,6 +793,7 @@ export class DaemonAgentConnection implements AgentConnection { } const reconnectPromise = this.restoreConnectionAfterUpdate() .catch(async (error: unknown) => { + this.updateRestartPending = false; if (!this.disposed) { await this.emit({ type: "closed", diff --git a/packages/coding-agent/test/agent-connection-daemon.test.ts b/packages/coding-agent/test/agent-connection-daemon.test.ts index 98781e9e19..af8aa4e3c9 100644 --- a/packages/coding-agent/test/agent-connection-daemon.test.ts +++ b/packages/coding-agent/test/agent-connection-daemon.test.ts @@ -1,6 +1,6 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { getModel } from "@earendil-works/pi-ai"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { MissingSessionCwdError } from "../src/core/session-cwd.js"; import { SessionImportFileNotFoundError } from "../src/core/session-import-errors.js"; import { @@ -32,6 +32,7 @@ class FakeDaemonClient { attachResultFactory: ((command: Extract) => DaemonAttachResult) | undefined; closeCount = 0; reconnectCount = 0; + reconnectError: Error | undefined; abortBashUnknownCommand = false; abortAndClearQueueUnknownCommand = false; updateRestartSessions: Array> = []; @@ -373,6 +374,9 @@ class FakeDaemonClient { async reconnect(): Promise { this.reconnectCount++; + if (this.reconnectError) { + throw this.reconnectError; + } } getMessageListenerCount(): number { @@ -555,6 +559,7 @@ describe("DaemonAgentConnection", () => { activeSessionId: "active-original", reason: "update", }); + expect(fakeClient.reconnectCount).toBe(1); fakeClient.emitClose(new Error("Daemon socket closed")); await expect(restored).resolves.toMatchObject({ @@ -577,6 +582,45 @@ describe("DaemonAgentConnection", () => { ]); }); + it("returns to normal close handling after update restoration times out", async () => { + vi.useFakeTimers(); + try { + const fakeClient = new FakeDaemonClient(); + fakeClient.reconnectError = new Error("daemon unavailable"); + const connection = new DaemonAgentConnection(asDaemonClient(fakeClient), "active-original"); + const closedEvents: AgentConnectionEvent[] = []; + connection.subscribe((event) => { + if (event.type === "closed") { + closedEvents.push(event); + } + }); + await connection.attach(); + + fakeClient.emitMessage({ + type: "session_closed", + activeSessionId: "active-original", + reason: "update", + }); + await vi.advanceTimersByTimeAsync(120100); + + expect(closedEvents).toEqual([ + { + type: "closed", + error: "Failed to reconnect after update: daemon unavailable", + }, + ]); + const reconnectCountAfterFailure = fakeClient.reconnectCount; + fakeClient.emitClose(new Error("later disconnect")); + await Promise.resolve(); + + expect(fakeClient.reconnectCount).toBe(reconnectCountAfterFailure); + expect(closedEvents.at(-1)).toEqual({ type: "closed", error: "later disconnect" }); + await connection.dispose(); + } finally { + vi.useRealTimers(); + } + }); + it("reattaches using the replacement session identity after a session switch", async () => { const fakeClient = new FakeDaemonClient(); const restoredMessages: AgentMessage[] = [{ role: "user", content: "switched prompt", timestamp: 3 }]; From d55c23608a1881c0b0460e6aca8aa0287b979972 Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Thu, 9 Jul 2026 18:22:26 -0700 Subject: [PATCH 07/14] fix(coding-agent): recover unannounced update closes --- .../daemon-agent-connection.ts | 7 +++- .../test/agent-connection-daemon.test.ts | 41 ++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index b2c1e9b9b4..9e694c49ad 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -95,6 +95,7 @@ export class DaemonAgentConnection implements AgentConnection { private attachedSessionId: string | undefined; private attachedSessionFile: string | undefined; private updateRestartPending = false; + private updateReconnectFailed = false; private updateReconnectPromise?: Promise; private disposed = false; @@ -107,7 +108,9 @@ export class DaemonAgentConnection implements AgentConnection { void this.handleDaemonMessage(message); }); this.unsubscribeDaemonClose = this.client.onClose((error) => { - if (this.updateRestartPending && !this.disposed) { + const unannouncedUpdateCandidate = error.message === "Daemon socket closed" && !this.updateReconnectFailed; + if (!this.disposed && (this.updateRestartPending || unannouncedUpdateCandidate)) { + this.updateRestartPending = true; void this.reconnectAfterUpdate(); return; } @@ -150,6 +153,7 @@ export class DaemonAgentConnection implements AgentConnection { const summary = "snapshot" in result ? result.snapshot.summary : result; this.attachedSessionId = summary.sessionId; this.attachedSessionFile = summary.sessionFile; + this.updateReconnectFailed = false; this.lastEventSequence = maxEventSequence(this.lastEventSequence, getAttachLastEventSequence(result)); if ("snapshot" in result) { this.latestSnapshot = mapDaemonAttachSnapshot(result); @@ -794,6 +798,7 @@ export class DaemonAgentConnection implements AgentConnection { const reconnectPromise = this.restoreConnectionAfterUpdate() .catch(async (error: unknown) => { this.updateRestartPending = false; + this.updateReconnectFailed = true; if (!this.disposed) { await this.emit({ type: "closed", diff --git a/packages/coding-agent/test/agent-connection-daemon.test.ts b/packages/coding-agent/test/agent-connection-daemon.test.ts index af8aa4e3c9..e751ec5adc 100644 --- a/packages/coding-agent/test/agent-connection-daemon.test.ts +++ b/packages/coding-agent/test/agent-connection-daemon.test.ts @@ -582,6 +582,43 @@ describe("DaemonAgentConnection", () => { ]); }); + it("reattaches after a clean socket close arrives before the update notice", async () => { + const fakeClient = new FakeDaemonClient(); + const restoredMessages: AgentMessage[] = [{ role: "user", content: "restored prompt", timestamp: 2 }]; + fakeClient.updateRestartSessions = [ + { + id: "active-restored", + activeSessionId: "active-restored", + sessionId: "session-current", + sessionFile: "/tmp/session-current.jsonl", + }, + ]; + fakeClient.attachResultFactory = (command) => + createAttachResult(command.activeSessionId, command.clientId, command.capabilities, 1, { + state: createConnectionState(command.activeSessionId, "session-current"), + messages: command.activeSessionId === "active-restored" ? restoredMessages : [], + }); + const connection = new DaemonAgentConnection(asDaemonClient(fakeClient), "active-original"); + const restored = new Promise((resolve) => { + connection.subscribe((event) => { + if (event.type === "session_replaced") { + resolve(event); + } + }); + }); + await connection.attach(); + + fakeClient.emitClose(new Error("Daemon socket closed")); + + await expect(restored).resolves.toMatchObject({ + type: "session_replaced", + state: { activeSessionId: "active-restored", sessionId: "session-current" }, + messages: restoredMessages, + }); + expect(fakeClient.reconnectCount).toBe(1); + expect(fakeClient.requests.map((request) => request.type)).toEqual(["attach", "list", "attach"]); + }); + it("returns to normal close handling after update restoration times out", async () => { vi.useFakeTimers(); try { @@ -610,11 +647,11 @@ describe("DaemonAgentConnection", () => { }, ]); const reconnectCountAfterFailure = fakeClient.reconnectCount; - fakeClient.emitClose(new Error("later disconnect")); + fakeClient.emitClose(new Error("Daemon socket closed")); await Promise.resolve(); expect(fakeClient.reconnectCount).toBe(reconnectCountAfterFailure); - expect(closedEvents.at(-1)).toEqual({ type: "closed", error: "later disconnect" }); + expect(closedEvents.at(-1)).toEqual({ type: "closed", error: "Daemon socket closed" }); await connection.dispose(); } finally { vi.useRealTimers(); From e414b4077bf0e1d15f535bc632d750342c15d179 Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Thu, 9 Jul 2026 18:28:32 -0700 Subject: [PATCH 08/14] fix(coding-agent): cancel update restore on dispose --- .../daemon-agent-connection.ts | 15 ++++++ .../test/agent-connection-daemon.test.ts | 48 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index 9e694c49ad..ecf940944e 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -826,7 +826,13 @@ export class DaemonAgentConnection implements AgentConnection { while (!this.disposed && Date.now() < deadline) { try { await this.client.reconnect(1000); + if (this.disposed) { + return; + } const response = await this.client.request({ type: "list" }, 30000); + if (this.disposed) { + return; + } if (!response.success) { throw deserializeDaemonError(response); } @@ -838,10 +844,19 @@ export class DaemonAgentConnection implements AgentConnection { (sessionId !== undefined && summary.sessionId === sessionId)), ); if (restored?.activeSessionId) { + if (this.disposed) { + return; + } this.activeSessionId = restored.activeSessionId; this.lastEventSequence = undefined; await this.attach(); + if (this.disposed) { + return; + } const snapshot = await this.getInitialSnapshot(); + if (this.disposed) { + return; + } this.updateRestartPending = false; await this.emit({ type: "session_replaced", state: snapshot.state, messages: snapshot.messages }); return; diff --git a/packages/coding-agent/test/agent-connection-daemon.test.ts b/packages/coding-agent/test/agent-connection-daemon.test.ts index e751ec5adc..f8a9edf070 100644 --- a/packages/coding-agent/test/agent-connection-daemon.test.ts +++ b/packages/coding-agent/test/agent-connection-daemon.test.ts @@ -30,6 +30,8 @@ class FakeDaemonClient { readonly requests: DaemonCommand[] = []; readonly requestTimeouts: number[] = []; attachResultFactory: ((command: Extract) => DaemonAttachResult) | undefined; + restoredAttachGate: Promise | undefined; + restoredAttachCompleted = 0; closeCount = 0; reconnectCount = 0; reconnectError: Error | undefined; @@ -55,6 +57,10 @@ class FakeDaemonClient { data: { sessions: this.updateRestartSessions }, }; case "attach": + if (command.activeSessionId === "active-restored" && this.restoredAttachGate) { + await this.restoredAttachGate; + this.restoredAttachCompleted++; + } if (command.activeSessionId === "missing") { return { type: "response", @@ -619,6 +625,48 @@ describe("DaemonAgentConnection", () => { expect(fakeClient.requests.map((request) => request.type)).toEqual(["attach", "list", "attach"]); }); + it("does not emit a restored session after disposal begins", async () => { + const fakeClient = new FakeDaemonClient(); + fakeClient.updateRestartSessions = [ + { + id: "active-restored", + activeSessionId: "active-restored", + sessionId: "session-current", + sessionFile: "/tmp/session-current.jsonl", + }, + ]; + let releaseRestoredAttach: (() => void) | undefined; + fakeClient.restoredAttachGate = new Promise((resolve) => { + releaseRestoredAttach = resolve; + }); + const connection = new DaemonAgentConnection(asDaemonClient(fakeClient), "active-original"); + const events: AgentConnectionEvent[] = []; + connection.subscribe((event) => { + events.push(event); + }); + await connection.attach(); + + fakeClient.emitClose(new Error("Daemon socket closed")); + await vi.waitFor(() => { + expect( + fakeClient.requests.some( + (request) => request.type === "attach" && request.activeSessionId === "active-restored", + ), + ).toBe(true); + }); + await connection.dispose(); + releaseRestoredAttach?.(); + await vi.waitFor(() => { + expect(fakeClient.restoredAttachCompleted).toBe(1); + }); + for (let flush = 0; flush < 5; flush++) { + await Promise.resolve(); + } + + expect(events).toEqual([]); + expect(fakeClient.requests.at(-1)).toMatchObject({ type: "detach", activeSessionId: "active-restored" }); + }); + it("returns to normal close handling after update restoration times out", async () => { vi.useFakeTimers(); try { From 123bc96d998b37f0132cd5d378e074e8803182ee Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Thu, 9 Jul 2026 18:32:27 -0700 Subject: [PATCH 09/14] fix(coding-agent): preserve explicit daemon shutdown --- .../daemon-agent-connection.ts | 10 +++++++- .../test/agent-connection-daemon.test.ts | 25 ++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index ecf940944e..5ae09c0578 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -96,6 +96,7 @@ export class DaemonAgentConnection implements AgentConnection { private attachedSessionFile: string | undefined; private updateRestartPending = false; private updateReconnectFailed = false; + private terminalCloseEmitted = false; private updateReconnectPromise?: Promise; private disposed = false; @@ -108,12 +109,16 @@ export class DaemonAgentConnection implements AgentConnection { void this.handleDaemonMessage(message); }); this.unsubscribeDaemonClose = this.client.onClose((error) => { + if (this.disposed || this.terminalCloseEmitted) { + return; + } const unannouncedUpdateCandidate = error.message === "Daemon socket closed" && !this.updateReconnectFailed; - if (!this.disposed && (this.updateRestartPending || unannouncedUpdateCandidate)) { + if (this.updateRestartPending || unannouncedUpdateCandidate) { this.updateRestartPending = true; void this.reconnectAfterUpdate(); return; } + this.terminalCloseEmitted = true; void this.emit({ type: "closed", error: error.message }); }); } @@ -154,6 +159,7 @@ export class DaemonAgentConnection implements AgentConnection { this.attachedSessionId = summary.sessionId; this.attachedSessionFile = summary.sessionFile; this.updateReconnectFailed = false; + this.terminalCloseEmitted = false; this.lastEventSequence = maxEventSequence(this.lastEventSequence, getAttachLastEventSequence(result)); if ("snapshot" in result) { this.latestSnapshot = mapDaemonAttachSnapshot(result); @@ -787,6 +793,7 @@ export class DaemonAgentConnection implements AgentConnection { void this.reconnectAfterUpdate(); return; } + this.terminalCloseEmitted = true; await this.emit({ type: "closed", error: message.reason }); } } @@ -800,6 +807,7 @@ export class DaemonAgentConnection implements AgentConnection { this.updateRestartPending = false; this.updateReconnectFailed = true; if (!this.disposed) { + this.terminalCloseEmitted = true; await this.emit({ type: "closed", error: `Failed to reconnect after update: ${error instanceof Error ? error.message : String(error)}`, diff --git a/packages/coding-agent/test/agent-connection-daemon.test.ts b/packages/coding-agent/test/agent-connection-daemon.test.ts index f8a9edf070..7fc36115c7 100644 --- a/packages/coding-agent/test/agent-connection-daemon.test.ts +++ b/packages/coding-agent/test/agent-connection-daemon.test.ts @@ -625,6 +625,29 @@ describe("DaemonAgentConnection", () => { expect(fakeClient.requests.map((request) => request.type)).toEqual(["attach", "list", "attach"]); }); + it("does not reconnect after an explicit shutdown session close", async () => { + const fakeClient = new FakeDaemonClient(); + const connection = new DaemonAgentConnection(asDaemonClient(fakeClient), "active-original"); + const closedEvents: AgentConnectionEvent[] = []; + connection.subscribe((event) => { + if (event.type === "closed") { + closedEvents.push(event); + } + }); + await connection.attach(); + + fakeClient.emitMessage({ + type: "session_closed", + activeSessionId: "active-original", + reason: "shutdown", + }); + fakeClient.emitClose(new Error("Daemon socket closed")); + await Promise.resolve(); + + expect(fakeClient.reconnectCount).toBe(0); + expect(closedEvents).toEqual([{ type: "closed", error: "shutdown" }]); + }); + it("does not emit a restored session after disposal begins", async () => { const fakeClient = new FakeDaemonClient(); fakeClient.updateRestartSessions = [ @@ -699,7 +722,7 @@ describe("DaemonAgentConnection", () => { await Promise.resolve(); expect(fakeClient.reconnectCount).toBe(reconnectCountAfterFailure); - expect(closedEvents.at(-1)).toEqual({ type: "closed", error: "Daemon socket closed" }); + expect(closedEvents).toHaveLength(1); await connection.dispose(); } finally { vi.useRealTimers(); From c9715c5b38211a22a6bf5d9f5327b0f2d5dd77aa Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Thu, 9 Jul 2026 18:36:13 -0700 Subject: [PATCH 10/14] fix(coding-agent): drop stale update transport --- .../src/modes/agent-connection/daemon-agent-connection.ts | 3 +++ packages/coding-agent/test/agent-connection-daemon.test.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index 5ae09c0578..632904949c 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -802,6 +802,9 @@ export class DaemonAgentConnection implements AgentConnection { if (this.updateReconnectPromise) { return this.updateReconnectPromise; } + // The update notice can arrive before the old transport closes. Drop it once + // so reconnect() cannot keep polling the pre-update daemon. + this.client.close(); const reconnectPromise = this.restoreConnectionAfterUpdate() .catch(async (error: unknown) => { this.updateRestartPending = false; diff --git a/packages/coding-agent/test/agent-connection-daemon.test.ts b/packages/coding-agent/test/agent-connection-daemon.test.ts index 7fc36115c7..86b3ad8966 100644 --- a/packages/coding-agent/test/agent-connection-daemon.test.ts +++ b/packages/coding-agent/test/agent-connection-daemon.test.ts @@ -565,6 +565,7 @@ describe("DaemonAgentConnection", () => { activeSessionId: "active-original", reason: "update", }); + expect(fakeClient.closeCount).toBe(1); expect(fakeClient.reconnectCount).toBe(1); fakeClient.emitClose(new Error("Daemon socket closed")); From 1cce4ed9af128fe46c7d72d48695d4dee87d3fa5 Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Thu, 9 Jul 2026 18:40:17 -0700 Subject: [PATCH 11/14] fix(coding-agent): guard update reconnect reentrancy --- .../modes/agent-connection/daemon-agent-connection.ts | 11 +++++++---- .../coding-agent/test/agent-connection-daemon.test.ts | 7 ++++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index 632904949c..ab81b35e15 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -802,10 +802,13 @@ export class DaemonAgentConnection implements AgentConnection { if (this.updateReconnectPromise) { return this.updateReconnectPromise; } - // The update notice can arrive before the old transport closes. Drop it once - // so reconnect() cannot keep polling the pre-update daemon. - this.client.close(); - const reconnectPromise = this.restoreConnectionAfterUpdate() + const reconnectPromise = Promise.resolve() + .then(() => { + // Register the recovery operation before dropping the old transport so + // a synchronous close notification cannot start a second restore loop. + this.client.close(); + return this.restoreConnectionAfterUpdate(); + }) .catch(async (error: unknown) => { this.updateRestartPending = false; this.updateReconnectFailed = true; diff --git a/packages/coding-agent/test/agent-connection-daemon.test.ts b/packages/coding-agent/test/agent-connection-daemon.test.ts index 86b3ad8966..b56c1d76ff 100644 --- a/packages/coding-agent/test/agent-connection-daemon.test.ts +++ b/packages/coding-agent/test/agent-connection-daemon.test.ts @@ -33,6 +33,7 @@ class FakeDaemonClient { restoredAttachGate: Promise | undefined; restoredAttachCompleted = 0; closeCount = 0; + emitCloseOnClose = false; reconnectCount = 0; reconnectError: Error | undefined; abortBashUnknownCommand = false; @@ -395,6 +396,9 @@ class FakeDaemonClient { close(): void { this.closeCount++; + if (this.emitCloseOnClose) { + this.emitClose(new Error("Daemon socket closed")); + } } } @@ -534,6 +538,7 @@ function emitSequencedQueueUpdate(client: FakeDaemonClient, activeSessionId: str describe("DaemonAgentConnection", () => { it("reattaches an open window to its restored session after an update restart", async () => { const fakeClient = new FakeDaemonClient(); + fakeClient.emitCloseOnClose = true; const restoredMessages: AgentMessage[] = [{ role: "user", content: "restored prompt", timestamp: 2 }]; fakeClient.updateRestartSessions = [ { @@ -565,9 +570,9 @@ describe("DaemonAgentConnection", () => { activeSessionId: "active-original", reason: "update", }); + await Promise.resolve(); expect(fakeClient.closeCount).toBe(1); expect(fakeClient.reconnectCount).toBe(1); - fakeClient.emitClose(new Error("Daemon socket closed")); await expect(restored).resolves.toMatchObject({ type: "session_replaced", From 6d293184e48ddefdff31b2bcd492945ac74f9167 Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Thu, 9 Jul 2026 18:46:56 -0700 Subject: [PATCH 12/14] fix(coding-agent): coordinate shared update reconnect --- .../daemon-agent-connection.ts | 40 +++++++++-- .../test/agent-connection-daemon.test.ts | 68 +++++++++++++++++++ 2 files changed, 101 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index ab81b35e15..bcaad231d9 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -62,11 +62,42 @@ type DaemonCommandBody = DistributiveOmit; export const DAEMON_REFINE_REQUEST_TIMEOUT_MS = 10 * 60 * 1000; const UPDATE_RECONNECT_TIMEOUT_MS = 120000; const UPDATE_RECONNECT_RETRY_MS = 100; +const updateTransportReconnects = new WeakMap>(); function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function reconnectDaemonTransportAfterUpdate(client: DaemonClient): Promise { + const existing = updateTransportReconnects.get(client); + if (existing) { + return existing; + } + const reconnectPromise = Promise.resolve() + .then(async () => { + client.close(); + const deadline = Date.now() + UPDATE_RECONNECT_TIMEOUT_MS; + let lastError: unknown; + while (Date.now() < deadline) { + try { + await client.reconnect(1000); + return; + } catch (error) { + lastError = error; + } + await delay(UPDATE_RECONNECT_RETRY_MS); + } + throw lastError ?? new Error("the updated daemon did not become available"); + }) + .finally(() => { + if (updateTransportReconnects.get(client) === reconnectPromise) { + updateTransportReconnects.delete(client); + } + }); + updateTransportReconnects.set(client, reconnectPromise); + return reconnectPromise; +} + export interface DaemonAgentConnectionOptions { closeClientOnDispose?: boolean; /** @@ -802,13 +833,8 @@ export class DaemonAgentConnection implements AgentConnection { if (this.updateReconnectPromise) { return this.updateReconnectPromise; } - const reconnectPromise = Promise.resolve() - .then(() => { - // Register the recovery operation before dropping the old transport so - // a synchronous close notification cannot start a second restore loop. - this.client.close(); - return this.restoreConnectionAfterUpdate(); - }) + const reconnectPromise = reconnectDaemonTransportAfterUpdate(this.client) + .then(() => this.restoreConnectionAfterUpdate()) .catch(async (error: unknown) => { this.updateRestartPending = false; this.updateReconnectFailed = true; diff --git a/packages/coding-agent/test/agent-connection-daemon.test.ts b/packages/coding-agent/test/agent-connection-daemon.test.ts index b56c1d76ff..4600aff417 100644 --- a/packages/coding-agent/test/agent-connection-daemon.test.ts +++ b/packages/coding-agent/test/agent-connection-daemon.test.ts @@ -34,6 +34,7 @@ class FakeDaemonClient { restoredAttachCompleted = 0; closeCount = 0; emitCloseOnClose = false; + connected = true; reconnectCount = 0; reconnectError: Error | undefined; abortBashUnknownCommand = false; @@ -380,10 +381,14 @@ class FakeDaemonClient { } async reconnect(): Promise { + if (this.connected) { + return; + } this.reconnectCount++; if (this.reconnectError) { throw this.reconnectError; } + this.connected = true; } getMessageListenerCount(): number { @@ -396,6 +401,7 @@ class FakeDaemonClient { close(): void { this.closeCount++; + this.connected = false; if (this.emitCloseOnClose) { this.emitClose(new Error("Daemon socket closed")); } @@ -631,6 +637,68 @@ describe("DaemonAgentConnection", () => { expect(fakeClient.requests.map((request) => request.type)).toEqual(["attach", "list", "attach"]); }); + it("coordinates one transport reconnect across connections sharing a daemon client", async () => { + const fakeClient = new FakeDaemonClient(); + fakeClient.emitCloseOnClose = true; + fakeClient.updateRestartSessions = [ + { + id: "restored-a", + activeSessionId: "restored-a", + sessionId: "session-a", + sessionFile: "/tmp/session-a.jsonl", + }, + { + id: "restored-b", + activeSessionId: "restored-b", + sessionId: "session-b", + sessionFile: "/tmp/session-b.jsonl", + }, + ]; + const sessionIds: Record = { + "active-a": "session-a", + "active-b": "session-b", + "restored-a": "session-a", + "restored-b": "session-b", + }; + fakeClient.attachResultFactory = (command) => + createAttachResult(command.activeSessionId, command.clientId, command.capabilities, 1, { + state: createConnectionState(command.activeSessionId, sessionIds[command.activeSessionId]!), + }); + const connectionA = new DaemonAgentConnection(asDaemonClient(fakeClient), "active-a"); + const connectionB = new DaemonAgentConnection(asDaemonClient(fakeClient), "active-b"); + await connectionA.attach(); + await connectionB.attach(); + const restoredA = new Promise((resolve) => { + connectionA.subscribe((event) => { + if (event.type === "session_replaced") { + resolve(event); + } + }); + }); + const restoredB = new Promise((resolve) => { + connectionB.subscribe((event) => { + if (event.type === "session_replaced") { + resolve(event); + } + }); + }); + + fakeClient.emitMessage({ type: "session_closed", activeSessionId: "active-a", reason: "update" }); + + await expect(Promise.all([restoredA, restoredB])).resolves.toEqual([ + expect.objectContaining({ + type: "session_replaced", + state: expect.objectContaining({ sessionId: "session-a" }), + }), + expect.objectContaining({ + type: "session_replaced", + state: expect.objectContaining({ sessionId: "session-b" }), + }), + ]); + expect(fakeClient.closeCount).toBe(1); + expect(fakeClient.reconnectCount).toBe(1); + }); + it("does not reconnect after an explicit shutdown session close", async () => { const fakeClient = new FakeDaemonClient(); const connection = new DaemonAgentConnection(asDaemonClient(fakeClient), "active-original"); From 13247bb07b8e4ff1320febf97cd7d81b063aa7a2 Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Thu, 9 Jul 2026 19:00:34 -0700 Subject: [PATCH 13/14] fix(coding-agent): explain daemon connection failures --- packages/coding-agent/CHANGELOG.md | 1 + .../daemon-agent-connection.ts | 70 ++++++++++++++++-- .../src/modes/daemon/daemon-client.ts | 60 ++++++++++++--- .../test/agent-connection-daemon.test.ts | 73 +++++++++++++++++-- .../coding-agent/test/daemon-client.test.ts | 38 ++++++++-- 5 files changed, 215 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b6570d71e8..bb92ea44a7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -14,6 +14,7 @@ - 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)). - Fixed self-updates losing restored daemon sessions to a socket cleanup race and leaving open session or agents-view windows disconnected. +- Changed daemon connection errors to report the failed operation, session identity, recovery steps, socket, and diagnostic log instead of raw protocol reasons. ## [0.2.7] - 2026-07-08 diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index bcaad231d9..d7e126fe41 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -1,13 +1,14 @@ import { randomUUID } from "node:crypto"; import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ImageContent, Transport } from "@earendil-works/pi-ai"; +import { getAgentLogPath, getDaemonLogPath } from "../../config.js"; import type { CompactionResult } from "../../core/compaction/index.js"; import type { ContextTreeNode } from "../../core/context-tree.js"; import type { AgentCronJob, AgentHeartbeatUpdateAction } from "../../core/cron-jobs.js"; import type { RefinementResult } from "../../core/refinement/index.js"; import type { DeleteSessionFileResult } from "../../core/session-file-actions.js"; import type { SessionStats } from "../../core/session-stats.js"; -import type { DaemonClient } from "../daemon/daemon-client.js"; +import { type DaemonClient, isDaemonSocketClosedError } from "../daemon/daemon-client.js"; import { deserializeDaemonError } from "../daemon/daemon-errors.js"; import { collectDaemonClientEnv, @@ -15,6 +16,7 @@ import { type DaemonCommand, type DaemonOutbound, type DaemonReplayInfo, + type DaemonSessionClosedReason, type DaemonSessionSnapshot, isUnknownDaemonCommandError, } from "../daemon/daemon-protocol.js"; @@ -68,6 +70,14 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function formatErrorSentence(error: unknown): string { + const message = (error instanceof Error ? error.message : String(error)).trim(); + if (!message) { + return "Unknown daemon error."; + } + return /[.!?]$/.test(message) ? message : `${message}.`; +} + function reconnectDaemonTransportAfterUpdate(client: DaemonClient): Promise { const existing = updateTransportReconnects.get(client); if (existing) { @@ -125,6 +135,7 @@ export class DaemonAgentConnection implements AgentConnection { private latestSnapshotIsFresh = false; private attachedSessionId: string | undefined; private attachedSessionFile: string | undefined; + private daemonLogPath: string | undefined; private updateRestartPending = false; private updateReconnectFailed = false; private terminalCloseEmitted = false; @@ -139,18 +150,19 @@ export class DaemonAgentConnection implements AgentConnection { this.unsubscribeDaemonMessages = this.client.onMessage((message) => { void this.handleDaemonMessage(message); }); + this.captureDaemonLogPath(); this.unsubscribeDaemonClose = this.client.onClose((error) => { if (this.disposed || this.terminalCloseEmitted) { return; } - const unannouncedUpdateCandidate = error.message === "Daemon socket closed" && !this.updateReconnectFailed; + const unannouncedUpdateCandidate = isDaemonSocketClosedError(error) && !this.updateReconnectFailed; if (this.updateRestartPending || unannouncedUpdateCandidate) { this.updateRestartPending = true; void this.reconnectAfterUpdate(); return; } this.terminalCloseEmitted = true; - void this.emit({ type: "closed", error: error.message }); + void this.emit({ type: "closed", error: this.formatDaemonConnectionClosedError(error) }); }); } @@ -188,7 +200,9 @@ export class DaemonAgentConnection implements AgentConnection { this.activeSessionId = getAttachActiveSessionId(result); const summary = "snapshot" in result ? result.snapshot.summary : result; this.attachedSessionId = summary.sessionId; - this.attachedSessionFile = summary.sessionFile; + this.attachedSessionFile = + summary.sessionFile ?? ("snapshot" in result ? result.snapshot.state.sessionFile : undefined); + this.captureDaemonLogPath(); this.updateReconnectFailed = false; this.terminalCloseEmitted = false; this.lastEventSequence = maxEventSequence(this.lastEventSequence, getAttachLastEventSequence(result)); @@ -820,13 +834,57 @@ export class DaemonAgentConnection implements AgentConnection { } if (message.type === "session_closed") { if (message.reason === "update") { + this.captureDaemonLogPath(); this.updateRestartPending = true; void this.reconnectAfterUpdate(); return; } this.terminalCloseEmitted = true; - await this.emit({ type: "closed", error: message.reason }); + await this.emit({ type: "closed", error: this.formatDaemonSessionClosedError(message.reason) }); + } + } + + private captureDaemonLogPath(): void { + const socketPath = this.client.hello?.socketPath; + if (socketPath) { + this.daemonLogPath = getDaemonLogPath(socketPath); + } + } + + private formatDaemonSessionClosedError(reason: DaemonSessionClosedReason): string { + const explanation: Record = { + killed: + "The daemon stopped this agent session. Its transcript remains saved and can be reopened from Agents View.", + shutdown: + "The Prime Agent daemon shut down while this window was attached. The session transcript remains saved; restart Prime Agent and reopen it from Agents View.", + completed: + "The daemon closed this agent session after it completed. Its transcript remains available from Agents View.", + replaced: + "The daemon replaced this agent session with another session. Reopen the current session from Agents View.", + update: + "The Prime Agent daemon restarted for an update, but this window did not restore automatically. The session transcript remains saved; restart Prime Agent and reopen it from Agents View.", + }; + return `${explanation[reason]} ${this.formatDaemonDiagnosticContext()}`; + } + + private formatDaemonConnectionClosedError(error: Error): string { + return `Lost connection to the Prime Agent daemon. Cause: ${formatErrorSentence(error)} The session transcript remains saved; restart Prime Agent or reopen the session from Agents View. ${this.formatDaemonDiagnosticContext()}`; + } + + private formatUpdateReconnectError(error: unknown): string { + return `The Prime Agent daemon restarted for an update, but this window could not reconnect to its restored session before the recovery timeout expired. Last error: ${formatErrorSentence(error)} The session transcript remains saved; restart Prime Agent and reopen it from Agents View. ${this.formatDaemonDiagnosticContext()}`; + } + + private formatDaemonDiagnosticContext(): string { + const details: string[] = []; + if (this.attachedSessionId) { + details.push(`Session ID: ${this.attachedSessionId}.`); + } + if (this.attachedSessionFile) { + details.push(`Session file: ${this.attachedSessionFile}.`); } + details.push(`Diagnostic log: ${this.daemonLogPath ?? getAgentLogPath()}.`); + return details.join(" "); } private reconnectAfterUpdate(): Promise { @@ -842,7 +900,7 @@ export class DaemonAgentConnection implements AgentConnection { this.terminalCloseEmitted = true; await this.emit({ type: "closed", - error: `Failed to reconnect after update: ${error instanceof Error ? error.message : String(error)}`, + error: this.formatUpdateReconnectError(error), }); } }) diff --git a/packages/coding-agent/src/modes/daemon/daemon-client.ts b/packages/coding-agent/src/modes/daemon/daemon-client.ts index 0c6cdf0ba4..b194c75381 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-client.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-client.ts @@ -1,4 +1,5 @@ import { createConnection, type Socket } from "node:net"; +import { getDaemonLogPath } from "../../config.js"; import { attachJsonlLineReader, serializeJsonLine } from "../rpc/jsonl.js"; import type { DaemonCommand, @@ -21,6 +22,23 @@ export interface DaemonClientRequestOptions { onProgress?: DaemonClientProgressListener; } +const LEGACY_DAEMON_SOCKET_CLOSED_MESSAGE = "Daemon socket closed"; + +function daemonEndpointDetails(socketPath: string): string { + return `Socket: ${socketPath}. Daemon log: ${getDaemonLogPath(socketPath)}.`; +} + +class DaemonSocketClosedError extends Error { + constructor(socketPath: string) { + super(`Connection to the Prime Agent daemon closed. ${daemonEndpointDetails(socketPath)}`); + this.name = "DaemonSocketClosedError"; + } +} + +export function isDaemonSocketClosedError(error: Error): boolean { + return error instanceof DaemonSocketClosedError || error.message === LEGACY_DAEMON_SOCKET_CLOSED_MESSAGE; +} + export class DaemonClient { private socket?: Socket; private detachReader?: () => void; @@ -60,7 +78,9 @@ export class DaemonClient { return this.helloMessage; } if (!this.socket || this.socket.destroyed) { - throw new Error("Daemon client is not connected"); + throw new Error( + `Cannot wait for the Prime Agent daemon handshake because the daemon is not connected. ${daemonEndpointDetails(this.socketPath)}`, + ); } return new Promise((resolve, reject) => { const waiter = { @@ -68,7 +88,11 @@ export class DaemonClient { reject, timeout: setTimeout(() => { this.helloWaiters.delete(waiter); - reject(new Error("Timed out waiting for daemon hello")); + reject( + new Error( + `Timed out after ${timeoutMs}ms waiting for the Prime Agent daemon handshake. ${daemonEndpointDetails(this.socketPath)}`, + ), + ); }, timeoutMs), }; this.helloWaiters.add(waiter); @@ -77,7 +101,7 @@ export class DaemonClient { async connect(timeoutMs = 3000): Promise { if (this.socket) { - throw new Error("Daemon client is already connected"); + throw new Error(`Prime Agent daemon client is already connected. ${daemonEndpointDetails(this.socketPath)}`); } this.helloMessage = undefined; @@ -90,7 +114,11 @@ export class DaemonClient { cleanup(); this.clearSocketReference(socket); socket.destroy(); - reject(new Error(`Timed out connecting to daemon socket: ${this.socketPath}`)); + reject( + new Error( + `Timed out after ${timeoutMs}ms connecting to the Prime Agent daemon. ${daemonEndpointDetails(this.socketPath)}`, + ), + ); }, timeoutMs); const cleanup = () => { clearTimeout(timeout); @@ -104,14 +132,18 @@ export class DaemonClient { const onError = (error: Error) => { cleanup(); this.clearSocketReference(socket); - reject(error); + reject( + new Error( + `Failed to connect to the Prime Agent daemon: ${error.message}. ${daemonEndpointDetails(this.socketPath)}`, + ), + ); }; socket.once("connect", onConnect); socket.once("error", onError); }); socket.on("error", (error) => this.notifyClosed(socket, error)); - socket.on("close", () => this.notifyClosed(socket, new Error("Daemon socket closed"))); + socket.on("close", () => this.notifyClosed(socket, new DaemonSocketClosedError(this.socketPath))); } async reconnect(timeoutMs = 3000): Promise { @@ -152,7 +184,9 @@ export class DaemonClient { options: DaemonClientRequestOptions = {}, ): Promise { if (!this.socket || this.socket.destroyed) { - throw new Error("Daemon client is not connected"); + throw new Error( + `Cannot send daemon command "${command.type}" because the Prime Agent daemon is not connected. ${daemonEndpointDetails(this.socketPath)}`, + ); } const id = `daemon_${++this.requestId}`; @@ -161,7 +195,11 @@ export class DaemonClient { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingRequests.delete(id); - reject(new Error(`Timed out waiting for daemon response to ${command.type}`)); + reject( + new Error( + `Timed out after ${timeoutMs}ms waiting for the Prime Agent daemon response to "${command.type}". ${daemonEndpointDetails(this.socketPath)}`, + ), + ); }, timeoutMs); this.pendingRequests.set(id, { resolve, reject, timeout, onProgress: options.onProgress }); @@ -172,7 +210,11 @@ export class DaemonClient { close(): void { this.detachReader?.(); this.detachReader = undefined; - this.rejectAll(new Error("Daemon client closed")); + this.rejectAll( + new Error( + `Prime Agent daemon client closed before the operation completed. ${daemonEndpointDetails(this.socketPath)}`, + ), + ); this.socket?.end(); this.socket?.destroy(); this.socket = undefined; diff --git a/packages/coding-agent/test/agent-connection-daemon.test.ts b/packages/coding-agent/test/agent-connection-daemon.test.ts index 4600aff417..8835e7bb52 100644 --- a/packages/coding-agent/test/agent-connection-daemon.test.ts +++ b/packages/coding-agent/test/agent-connection-daemon.test.ts @@ -719,7 +719,62 @@ describe("DaemonAgentConnection", () => { await Promise.resolve(); expect(fakeClient.reconnectCount).toBe(0); - expect(closedEvents).toEqual([{ type: "closed", error: "shutdown" }]); + expect(closedEvents).toHaveLength(1); + expect(closedEvents[0]).toMatchObject({ + type: "closed", + error: expect.stringContaining("The Prime Agent daemon shut down while this window was attached."), + }); + const closedError = closedEvents[0]?.type === "closed" ? closedEvents[0].error : undefined; + expect(closedError).toContain("Session ID: session-current."); + expect(closedError).toContain("Session file: /tmp/session-current.jsonl."); + expect(closedError).toContain("Diagnostic log:"); + }); + + it.each([ + ["killed", "The daemon stopped this agent session."], + ["completed", "The daemon closed this agent session after it completed."], + ["replaced", "The daemon replaced this agent session with another session."], + ] as const)("explains a %s session close instead of exposing the raw reason", async (reason, explanation) => { + const fakeClient = new FakeDaemonClient(); + const connection = new DaemonAgentConnection(asDaemonClient(fakeClient), "active-original"); + const closedEvents: AgentConnectionEvent[] = []; + connection.subscribe((event) => { + if (event.type === "closed") { + closedEvents.push(event); + } + }); + await connection.attach(); + + fakeClient.emitMessage({ type: "session_closed", activeSessionId: "active-original", reason }); + await Promise.resolve(); + + expect(closedEvents).toHaveLength(1); + const closedError = closedEvents[0]?.type === "closed" ? closedEvents[0].error : undefined; + expect(closedError).toContain(explanation); + expect(closedError).not.toBe(reason); + expect(closedError).toContain("Session ID: session-current."); + }); + + it("adds recovery and diagnostic context to unexpected daemon disconnects", async () => { + const fakeClient = new FakeDaemonClient(); + const connection = new DaemonAgentConnection(asDaemonClient(fakeClient), "active-original"); + const closedEvents: AgentConnectionEvent[] = []; + connection.subscribe((event) => { + if (event.type === "closed") { + closedEvents.push(event); + } + }); + await connection.attach(); + + fakeClient.emitClose(new Error("ECONNRESET")); + await Promise.resolve(); + + expect(closedEvents).toHaveLength(1); + const closedError = closedEvents[0]?.type === "closed" ? closedEvents[0].error : undefined; + expect(closedError).toContain("Lost connection to the Prime Agent daemon. Cause: ECONNRESET"); + expect(closedError).toContain("restart Prime Agent or reopen the session from Agents View"); + expect(closedError).toContain("Session file: /tmp/session-current.jsonl."); + expect(closedError).toContain("Diagnostic log:"); }); it("does not emit a restored session after disposal begins", async () => { @@ -785,12 +840,16 @@ describe("DaemonAgentConnection", () => { }); await vi.advanceTimersByTimeAsync(120100); - expect(closedEvents).toEqual([ - { - type: "closed", - error: "Failed to reconnect after update: daemon unavailable", - }, - ]); + expect(closedEvents).toHaveLength(1); + const closedError = closedEvents[0]?.type === "closed" ? closedEvents[0].error : undefined; + expect(closedError).toContain( + "The Prime Agent daemon restarted for an update, but this window could not reconnect", + ); + expect(closedError).toContain("Last error: daemon unavailable"); + expect(closedError).toContain("restart Prime Agent and reopen it from Agents View"); + expect(closedError).toContain("Session ID: session-current."); + expect(closedError).toContain("Session file: /tmp/session-current.jsonl."); + expect(closedError).toContain("Diagnostic log:"); const reconnectCountAfterFailure = fakeClient.reconnectCount; fakeClient.emitClose(new Error("Daemon socket closed")); await Promise.resolve(); diff --git a/packages/coding-agent/test/daemon-client.test.ts b/packages/coding-agent/test/daemon-client.test.ts index c62181a0ff..33ee49a676 100644 --- a/packages/coding-agent/test/daemon-client.test.ts +++ b/packages/coding-agent/test/daemon-client.test.ts @@ -106,7 +106,10 @@ describe("DaemonClient", () => { firstSocket.emit("error", new Error("initial connect failed")); - await expect(firstAttempt).resolves.toMatchObject({ message: "initial connect failed" }); + const firstError = await firstAttempt; + expect(firstError.message).toContain("Failed to connect to the Prime Agent daemon: initial connect failed."); + expect(firstError.message).toContain("Socket: /tmp/prime-agent-missing.sock."); + expect(firstError.message).toContain("Daemon log:"); expect(firstSocket.listenerCount("data")).toBe(0); expect(firstSocket.listenerCount("end")).toBe(0); @@ -114,7 +117,9 @@ describe("DaemonClient", () => { expect(netMock.sockets).toHaveLength(2); netMock.sockets[1]!.emit("error", new Error("retry reached socket")); - await expect(secondAttempt).resolves.toMatchObject({ message: "retry reached socket" }); + await expect(secondAttempt).resolves.toMatchObject({ + message: expect.stringContaining("Failed to connect to the Prime Agent daemon: retry reached socket."), + }); }); it("allows connect retry after the initial connection times out", async () => { @@ -126,7 +131,7 @@ describe("DaemonClient", () => { const firstSocket = netMock.sockets[0]!; const timeoutRejection = expect(firstAttempt).resolves.toMatchObject({ - message: "Timed out connecting to daemon socket: /tmp/prime-agent-slow.sock", + message: expect.stringContaining("Timed out after 5ms connecting to the Prime Agent daemon."), }); await vi.advanceTimersByTimeAsync(5); await timeoutRejection; @@ -139,7 +144,9 @@ describe("DaemonClient", () => { expect(netMock.sockets).toHaveLength(2); netMock.sockets[1]!.emit("error", new Error("retry reached socket")); - await expect(secondAttempt).resolves.toMatchObject({ message: "retry reached socket" }); + await expect(secondAttempt).resolves.toMatchObject({ + message: expect.stringContaining("Failed to connect to the Prime Agent daemon: retry reached socket."), + }); }); it("captures the daemon hello greeting for version checks", async () => { @@ -224,6 +231,24 @@ describe("DaemonClient", () => { client.close(); }); + it("includes command, socket, and log context when a request is made while disconnected", async () => { + const client = new DaemonClient("/tmp/prime-agent.sock"); + + const request = client.request({ type: "list", all: true }); + + await expect(request).rejects.toMatchObject({ + message: expect.stringContaining( + 'Cannot send daemon command "list" because the Prime Agent daemon is not connected.', + ), + }); + await expect(request).rejects.toMatchObject({ + message: expect.stringContaining("Socket: /tmp/prime-agent.sock."), + }); + await expect(request).rejects.toMatchObject({ + message: expect.stringContaining("Daemon log:"), + }); + }); + it("routes request progress by response id without notifying general listeners", async () => { const client = new DaemonClient("/tmp/prime-agent.sock"); @@ -391,7 +416,10 @@ describe("DaemonClient", () => { socket.emit("close"); - expect(closed.map((error) => error.message)).toEqual(["Daemon socket closed"]); + expect(closed).toHaveLength(1); + expect(closed[0]?.message).toContain("Connection to the Prime Agent daemon closed."); + expect(closed[0]?.message).toContain("Socket: /tmp/prime-agent.sock."); + expect(closed[0]?.message).toContain("Daemon log:"); expect(client.isConnected).toBe(false); unsubscribe(); client.close(); From 4d309bad05a8e0176b4acf428538ee3ec0cafd1c Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Thu, 9 Jul 2026 20:15:51 -0700 Subject: [PATCH 14/14] fix(coding-agent): coordinate daemon close recovery --- .../daemon-agent-connection.ts | 7 ++-- .../src/modes/agents-view/agents-view-mode.ts | 3 +- .../src/modes/daemon/daemon-client.ts | 17 ++++++---- .../src/modes/daemon/daemon-mode.ts | 2 +- .../test/agent-connection-daemon.test.ts | 32 +++++++++++++++++-- .../coding-agent/test/daemon-client.test.ts | 21 ++++++++++++ 6 files changed, 67 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index 950d55245a..a2158e425e 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -8,7 +8,7 @@ import type { AgentCronJob, AgentHeartbeatUpdateAction } from "../../core/cron-j import type { RefinementResult } from "../../core/refinement/index.js"; import type { DeleteSessionFileResult } from "../../core/session-file-actions.js"; import type { SessionStats } from "../../core/session-stats.js"; -import { type DaemonClient, getDaemonSocketCloseReason, isDaemonSocketClosedError } from "../daemon/daemon-client.js"; +import { type DaemonClient, getDaemonSocketCloseReason } from "../daemon/daemon-client.js"; import { deserializeDaemonError } from "../daemon/daemon-errors.js"; import { collectDaemonClientEnv, @@ -86,7 +86,7 @@ function reconnectDaemonTransportAfterUpdate(client: DaemonClient): Promise { - client.close(); + client.disconnectForReconnect("update"); const deadline = Date.now() + UPDATE_RECONNECT_TIMEOUT_MS; let lastError: unknown; while (Date.now() < deadline) { @@ -163,8 +163,7 @@ export class DaemonAgentConnection implements AgentConnection { void this.emit({ type: "closed", error: this.formatDaemonSessionClosedError("shutdown") }); return; } - const unannouncedUpdateCandidate = isDaemonSocketClosedError(error) && !this.updateReconnectFailed; - if (this.updateRestartPending || unannouncedUpdateCandidate) { + if ((this.updateRestartPending || closeReason === "update") && !this.updateReconnectFailed) { this.updateRestartPending = true; void this.reconnectAfterUpdate(); return; diff --git a/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts b/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts index f403b8be21..14fe28e6e7 100644 --- a/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts +++ b/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts @@ -508,7 +508,6 @@ class AgentsViewMode implements Component, Focusable { }); await this.refreshSessions(); - await this.sendInitialPrompts(); this.loadStartupNotices(); this.pollTimer = setInterval(() => { void this.refreshSessions(); @@ -1749,6 +1748,7 @@ class AgentsViewMode implements Component, Focusable { const response = await client.request(createAgentsViewListCommand()); const data = requireDaemonData(response); this.applySessionList(expectSessionList(data)); + await this.sendInitialPrompts(); } catch (error) { if (!this.reconnectPromise) { if (client.isConnected) { @@ -1912,6 +1912,7 @@ class AgentsViewMode implements Component, Focusable { this.reconnectTimedOut = false; this.setStatusMessage("Reconnected after daemon restart", { render: false }); this.applySessionList(sessions); + await this.sendInitialPrompts(); return; } catch (error) { lastError = error; diff --git a/packages/coding-agent/src/modes/daemon/daemon-client.ts b/packages/coding-agent/src/modes/daemon/daemon-client.ts index 89afecf87d..96787fd78d 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-client.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-client.ts @@ -23,8 +23,6 @@ export interface DaemonClientRequestOptions { onProgress?: DaemonClientProgressListener; } -const LEGACY_DAEMON_SOCKET_CLOSED_MESSAGE = "Daemon socket closed"; - function daemonEndpointDetails(socketPath: string): string { return `Socket: ${socketPath}. Daemon log: ${getDaemonLogPath(socketPath)}.`; } @@ -44,10 +42,6 @@ export class DaemonSocketClosedError extends Error { } } -export function isDaemonSocketClosedError(error: Error): boolean { - return error instanceof DaemonSocketClosedError || error.message === LEGACY_DAEMON_SOCKET_CLOSED_MESSAGE; -} - export function getDaemonSocketCloseReason(error: Error): DaemonClosingReason | undefined { return error instanceof DaemonSocketClosedError ? error.daemonClosingReason : undefined; } @@ -188,6 +182,17 @@ export class DaemonClient { } } + disconnectForReconnect(reason: DaemonClosingReason): void { + const socket = this.socket; + if (!socket || socket.destroyed) { + return; + } + this.daemonClosingReason = reason; + this.notifyClosed(socket, new DaemonSocketClosedError(this.socketPath, reason)); + socket.end(); + socket.destroy(); + } + onMessage(listener: DaemonClientMessageListener): () => void { this.listeners.add(listener); return () => { diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 34189f95ce..c7a53db00a 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -2980,7 +2980,7 @@ export class AgentDaemon { } this.cronScheduler.stop(); for (const state of [...this.sessions.values()]) { - await this.closeSession(state, "shutdown"); + await this.closeSession(state, closingReason); } for (const client of this.clients) { client.detachInput(); diff --git a/packages/coding-agent/test/agent-connection-daemon.test.ts b/packages/coding-agent/test/agent-connection-daemon.test.ts index 0b2792729b..0b903530aa 100644 --- a/packages/coding-agent/test/agent-connection-daemon.test.ts +++ b/packages/coding-agent/test/agent-connection-daemon.test.ts @@ -408,6 +408,12 @@ class FakeDaemonClient { this.emitClose(new Error("Daemon socket closed")); } } + + disconnectForReconnect(reason: "shutdown" | "update"): void { + this.closeCount++; + this.connected = false; + this.emitClose(new DaemonSocketClosedError("/tmp/prime-agent.sock", reason)); + } } function asDaemonClient(client: FakeDaemonClient): DaemonClient { @@ -602,7 +608,7 @@ describe("DaemonAgentConnection", () => { ]); }); - it("reattaches after a clean socket close arrives before the update notice", async () => { + it("reattaches when an update socket close arrives before the session notice", async () => { const fakeClient = new FakeDaemonClient(); const restoredMessages: AgentMessage[] = [{ role: "user", content: "restored prompt", timestamp: 2 }]; fakeClient.updateRestartSessions = [ @@ -628,7 +634,7 @@ describe("DaemonAgentConnection", () => { }); await connection.attach(); - fakeClient.emitClose(new Error("Daemon socket closed")); + fakeClient.emitClose(new DaemonSocketClosedError("/tmp/prime-agent.sock", "update")); await expect(restored).resolves.toMatchObject({ type: "session_replaced", @@ -799,6 +805,26 @@ describe("DaemonAgentConnection", () => { expect(closedError).toContain("Diagnostic log:"); }); + it("reports an unannounced clean socket close without treating it as an update", async () => { + const fakeClient = new FakeDaemonClient(); + const connection = new DaemonAgentConnection(asDaemonClient(fakeClient), "active-original"); + const closedEvents: AgentConnectionEvent[] = []; + connection.subscribe((event) => { + if (event.type === "closed") { + closedEvents.push(event); + } + }); + await connection.attach(); + + fakeClient.emitClose(new DaemonSocketClosedError("/tmp/prime-agent.sock")); + await Promise.resolve(); + + expect(fakeClient.reconnectCount).toBe(0); + expect(closedEvents).toHaveLength(1); + const closedError = closedEvents[0]?.type === "closed" ? closedEvents[0].error : undefined; + expect(closedError).toContain("Lost connection to the Prime Agent daemon."); + }); + it("does not emit a restored session after disposal begins", async () => { const fakeClient = new FakeDaemonClient(); fakeClient.updateRestartSessions = [ @@ -820,7 +846,7 @@ describe("DaemonAgentConnection", () => { }); await connection.attach(); - fakeClient.emitClose(new Error("Daemon socket closed")); + fakeClient.emitClose(new DaemonSocketClosedError("/tmp/prime-agent.sock", "update")); await vi.waitFor(() => { expect( fakeClient.requests.some( diff --git a/packages/coding-agent/test/daemon-client.test.ts b/packages/coding-agent/test/daemon-client.test.ts index 6e2b5dc8f5..507b95a874 100644 --- a/packages/coding-agent/test/daemon-client.test.ts +++ b/packages/coding-agent/test/daemon-client.test.ts @@ -443,6 +443,27 @@ describe("DaemonClient", () => { client.close(); }); + it("notifies every listener before disconnecting a shared client for update reconnect", async () => { + const client = new DaemonClient("/tmp/prime-agent.sock"); + const connect = client.connect(); + const socket = netMock.sockets[0]!; + socket.emit("connect"); + await connect; + + const firstClosed: Error[] = []; + const secondClosed: Error[] = []; + client.onClose((error) => firstClosed.push(error)); + client.onClose((error) => secondClosed.push(error)); + + client.disconnectForReconnect("update"); + + expect(client.isConnected).toBe(false); + expect(firstClosed).toHaveLength(1); + expect(secondClosed).toHaveLength(1); + expect(getDaemonSocketCloseReason(firstClosed[0]!)).toBe("update"); + expect(getDaemonSocketCloseReason(secondClosed[0]!)).toBe("update"); + }); + it("notifies listeners once when a socket error is followed by close", async () => { const client = new DaemonClient("/tmp/prime-agent.sock");