From 00a867530e654f2a3732edb02a52aee638dee434 Mon Sep 17 00:00:00 2001 From: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com> Date: Thu, 21 May 2026 04:03:06 +0530 Subject: [PATCH] fix(cli): verify update pause before install --- .../cli/__tests__/commands/update.test.ts | 57 ++++++++++++++----- packages/cli/src/commands/update.ts | 31 ++++++++++ 2 files changed, 74 insertions(+), 14 deletions(-) diff --git a/packages/cli/__tests__/commands/update.test.ts b/packages/cli/__tests__/commands/update.test.ts index a19e2d1ab..62db6bdfe 100644 --- a/packages/cli/__tests__/commands/update.test.ts +++ b/packages/cli/__tests__/commands/update.test.ts @@ -570,13 +570,15 @@ describe("update command", () => { ); Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); - mockGetRunning.mockResolvedValue({ - pid: 12345, - configPath: "/tmp/test-global-config.yaml", - port: 3000, - startedAt: new Date().toISOString(), - projects: ["my-app"], - }); + mockGetRunning + .mockResolvedValueOnce({ + pid: 12345, + configPath: "/tmp/test-global-config.yaml", + port: 3000, + startedAt: new Date().toISOString(), + projects: ["my-app"], + }) + .mockResolvedValue(null); mockLoadConfig.mockImplementation((path?: string) => ({ projects: { "my-app": { path: "/tmp/foo" } }, configPath: path ?? "/cwd/agent-orchestrator.yaml", @@ -618,6 +620,7 @@ describe("update command", () => { }); it("does not stop/start when no daemon or active sessions exist", async () => { + mockGetRunning.mockReset(); mockGetRunning.mockResolvedValue(null); mockExistsSync.mockReturnValue(false); mockSessions.value = []; @@ -653,7 +656,30 @@ describe("update command", () => { ]); }); + it("aborts before install if ao stop exits 0 but AO still appears active", async () => { + mockGetRunning.mockResolvedValue({ + pid: 12345, + configPath: "/tmp/test-global-config.yaml", + port: 3000, + startedAt: new Date().toISOString(), + projects: ["my-app"], + }); + mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "" })); + + await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow( + "process.exit(1)", + ); + + expect(mockSpawn).toHaveBeenCalledTimes(1); + expect(mockSpawn.mock.calls[0][0]).toBe("ao"); + expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]); + expect(mockSpawn.mock.calls.some((call) => call[0] === "pnpm")).toBe(false); + const stderr = vi.mocked(console.error).mock.calls.map((c) => String(c[0])).join("\n"); + expect(stderr).toContain("AO still appears to be running after `ao stop --yes`"); + }); + it("prints a friendly pnpm diagnostic and npm fallback when pnpm fails", async () => { + mockGetRunning.mockReset(); mockGetRunning.mockResolvedValue(null); mockExistsSync.mockReturnValue(false); mockSpawn.mockReturnValue( @@ -955,13 +981,16 @@ describe("update command", () => { it("orchestrates stop/install/verify/start when active sessions exist and update is API-invoked", async () => { mockSessions.value = [{ id: "feat-1", status: "working" }]; - mockGetRunning.mockResolvedValue({ - pid: 12345, - configPath: "/tmp/test-global-config.yaml", - port: 3000, - startedAt: new Date().toISOString(), - projects: ["my-app"], - }); + mockExistsSync.mockReturnValue(false); + mockGetRunning + .mockResolvedValueOnce({ + pid: 12345, + configPath: "/tmp/test-global-config.yaml", + port: 3000, + startedAt: new Date().toISOString(), + projects: ["my-app"], + }) + .mockResolvedValue(null); await program.parseAsync(["node", "test", "update"]); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 472f37612..e9daaebb2 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -245,6 +245,37 @@ async function pauseAoForUpdate(plan: UpdateLifecyclePlan): Promise { process.exit(stopExit); } + const afterStop = await getUpdateLifecyclePlan(); + if (afterStop.runningBeforeUpdate || afterStop.activeSessions.length > 0) { + recordActivityEvent({ + source: "cli", + kind: "cli.update_failed", + level: "error", + summary: `ao update failed: AO still appears active after internal ao stop`, + data: { + runningAfterStop: afterStop.runningBeforeUpdate, + activeSessionCount: afterStop.activeSessions.length, + activeSessionIds: afterStop.activeSessions.map((s) => s.id).slice(0, 20), + }, + }); + console.error( + chalk.red( + "\nAO update stopped before installing because AO still appears to be running after `ao stop --yes`.", + ), + ); + if (afterStop.activeSessions.length > 0) { + console.error(chalk.dim("Still-active sessions:")); + for (const s of afterStop.activeSessions.slice(0, 5)) { + console.error(chalk.dim(` • ${s.id} (${s.status})`)); + } + if (afterStop.activeSessions.length > 5) { + console.error(chalk.dim(` … and ${afterStop.activeSessions.length - 5} more`)); + } + } + console.error(chalk.dim("Run `ao stop` and retry `ao update` after AO is fully stopped.")); + process.exit(1); + } + return true; }