From 6f6712be6279fa0717de0331b5dd09bcb0efff9d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Nov 2026 10:20:26 +0700 Subject: [PATCH] Make interrupted-agent recovery opt-in (#8619) * fix(agents): require opt-in for restart recovery Keep automatic interrupted-turn resumption off during further dogfooding. Preserve explicit opt-in and custom resume prompts. * test(settings): verify restart recovery opt-in persists * docs(agents): describe restart recovery opt-in --- docs/agent-lifecycle.md | 7 ++++--- .../app/e2e/browser/settings-host-page.spec.ts | 5 ++++- .../screens/settings/restart-recovery-card.tsx | 2 +- packages/server/src/server/bootstrap.ts | 4 ++-- packages/server/src/server/config.test.ts | 18 ++++++++++++++++++ packages/server/src/server/config.ts | 2 +- .../daemon-restart-resume.e2e.test.ts | 12 ++++++------ 7 files changed, 36 insertions(+), 14 deletions(-) diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 2c2ce29403..3eec9fb566 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -14,9 +14,10 @@ Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idl ## Daemon restart recovery -Hosts resume interrupted agents by default after graceful shutdown or a crash. The daemon -reopens the saved provider conversation and settings, then sends `Resume working.`. Change -`agents.resumePrompt` or disable `agents.resumeOnStartup` in host config or Host settings → Agents. +Restart recovery is off by default. Enable `agents.resumeOnStartup` in host config or +Host settings → Agents to resume interrupted agents after graceful shutdown or a crash. The daemon +reopens the saved provider conversation and settings, then sends `Resume working.`. Customize +the prompt with `agents.resumePrompt` or the same settings page. Disabling recovery consumes pending intent; enabling it later does not revive old work. Only agents recorded as running qualify. Idle and initializing agents stay inactive. Archived diff --git a/packages/app/e2e/browser/settings-host-page.spec.ts b/packages/app/e2e/browser/settings-host-page.spec.ts index d1507a105f..4ada0c7fac 100644 --- a/packages/app/e2e/browser/settings-host-page.spec.ts +++ b/packages/app/e2e/browser/settings-host-page.spec.ts @@ -262,12 +262,15 @@ test.describe("Settings host page", () => { }); }); -test("restart recovery host controls persist the opt-out and custom prompt", async ({ page }) => { +test("restart recovery defaults off and persists opt-in and custom prompt", async ({ page }) => { const serverId = getServerId(); await gotoAppShell(page); await openSettings(page); await openSettingsHost(page, serverId); await openHostSection(page, serverId, "agents"); + await expectRestartRecoveryEnabled(page, false); + await setRestartRecoveryEnabled(page, true); + await page.reload(); await expectRestartRecoveryEnabled(page, true); await expectRestartRecoveryPrompt(page, "Resume working."); await editRestartRecoveryPrompt(page, "Continue the interrupted work."); diff --git a/packages/app/src/screens/settings/restart-recovery-card.tsx b/packages/app/src/screens/settings/restart-recovery-card.tsx index 9fa8a872b0..8d9b61fbee 100644 --- a/packages/app/src/screens/settings/restart-recovery-card.tsx +++ b/packages/app/src/screens/settings/restart-recovery-card.tsx @@ -59,7 +59,7 @@ export function RestartRecoveryCard({ serverId }: { serverId: string }) { if (!isConnected || !isSupported || !config) return null; - const isEnabled = config.resumeOnStartup !== false; + const isEnabled = config.resumeOnStartup === true; return ( <> diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index fa896ef898..cf62c525ba 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -600,7 +600,7 @@ function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): Mutab autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false, enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false, idleRuntimeCleanup: config.idleRuntimeCleanup !== false, - resumeOnStartup: config.resumeOnStartup !== false, + resumeOnStartup: config.resumeOnStartup === true, resumePrompt: config.resumePrompt ?? "Resume working.", appendSystemPrompt: config.appendSystemPrompt ?? "", pluginsEnabled: config.pluginsEnabled ?? false, @@ -1047,7 +1047,7 @@ export async function createPaseoDaemon( const restartRecovery = createRestartRecovery({ storage: agentStorage, settings: () => ({ - enabled: daemonConfigStore.get().resumeOnStartup !== false, + enabled: daemonConfigStore.get().resumeOnStartup === true, prompt: daemonConfigStore.get().resumePrompt ?? "Resume working.", }), eligible: async (record) => diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index 53a77338b6..f453da7812 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -40,6 +40,24 @@ describe("server config", () => { expect(blockingMs).toBeLessThan(50); }); + test.each([undefined, false, true])( + "restart recovery requires explicit opt-in (%s)", + async (enabled) => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-recovery-")); + roots.push(paseoHome); + await writeFile( + path.join(paseoHome, "config.json"), + JSON.stringify({ + version: 1, + agents: { resumeOnStartup: enabled, resumePrompt: "Continue exactly.\n" }, + }), + ); + const config = loadConfig(paseoHome, { env: {} }); + expect(config.resumeOnStartup).toBe(enabled === true); + expect(config.resumePrompt).toBe("Continue exactly.\n"); + }, + ); + test("records when the daemon is managed by Paseo Desktop", async () => { const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-desktop-managed-")); roots.push(paseoHome); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 07c5c287b8..5340bbbcd3 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -839,7 +839,7 @@ function resolveSpeechOverrideControlledPaths( function resolveRestartRecoveryConfig(agents: PersistedConfig["agents"]) { return { - resumeOnStartup: agents?.resumeOnStartup ?? true, + resumeOnStartup: agents?.resumeOnStartup ?? false, resumePrompt: agents?.resumePrompt ?? "Resume working.", }; } diff --git a/packages/server/src/server/daemon-e2e/daemon-restart-resume.e2e.test.ts b/packages/server/src/server/daemon-e2e/daemon-restart-resume.e2e.test.ts index 1658356eed..ebe47893b3 100644 --- a/packages/server/src/server/daemon-e2e/daemon-restart-resume.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/daemon-restart-resume.e2e.test.ts @@ -134,7 +134,7 @@ test.each([false, true])( let client: DaemonClient | undefined; let mcp: Client | undefined; try { - const first = await restartFixture(root); + const first = await restartFixture(root, { resumeOnStartup: true }); processes.push(first.child); client = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws`, appVersion: "0.13.0" }); await client.connect(); @@ -182,7 +182,7 @@ test.each([false, true])( await client.close(); client = undefined; await stopFixture(first.child, abrupt); - const second = await restartFixture(root); + const second = await restartFixture(root, { resumeOnStartup: true }); processes.push(second.child); await expect.poll(() => readPrompts(root), { timeout: 15000 }).toHaveLength(1); const prompts = await readPrompts(root); @@ -222,7 +222,7 @@ test.each([false, true])( await client.close(); client = undefined; await stopFixture(second.child, false); - const third = await restartFixture(root); + const third = await restartFixture(root, { resumeOnStartup: true }); processes.push(third.child); expect(await readPrompts(root)).toHaveLength(1); expect( @@ -243,7 +243,7 @@ test.each([false, true])( 60000, ); -test.each([false, true])( +test.each([undefined, false, true])( "host restart recovery settings (enabled=%s)", async (enabled) => { const root = await mkdtemp(path.join(tmpdir(), "resume-settings-process-")); @@ -314,7 +314,7 @@ test("a provider that is slow to reopen does not hold daemon startup", async () const processes: ChildProcess[] = []; let client: DaemonClient | undefined; try { - const first = await restartFixture(root); + const first = await restartFixture(root, { resumeOnStartup: true }); processes.push(first.child); client = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws`, appVersion: "0.13.0" }); await client.connect(); @@ -338,7 +338,7 @@ test("a provider that is slow to reopen does not hold daemon startup", async () client = undefined; await stopFixture(first.child, false); - const second = await restartFixture(root, {}, { holdResume: true }); + const second = await restartFixture(root, { resumeOnStartup: true }, { holdResume: true }); processes.push(second.child); client = new DaemonClient({ url: `ws://127.0.0.1:${second.port}/ws`, appVersion: "0.13.0" }); await client.connect();