fix(cli): orchestrate ao update lifecycle + drop misleading ao stop picker (closes #1970, closes #1972) (#1973)

* fix(cli): orchestrate update lifecycle

* fix(cli): verify update pause before install

* fix(cli): avoid restart after orphan cleanup

---------

Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
This commit is contained in:
i-trytoohard 2026-05-21 04:22:22 +05:30 committed by GitHub
parent ecdf0c73ec
commit 37d3a86d6d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 740 additions and 400 deletions

View File

@ -210,6 +210,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
### ao start
- Registers in `running.json` (PID, port, projects)
- Offers to restore sessions from `last-stop.json` — includes cross-project sessions via `otherProjects` field
- `ao start --restore` restores `last-stop.json` without prompting; `ao start --no-restore` skips restore
- **Ctrl+C performs full graceful shutdown** (same as ao stop): kills all sessions, writes last-stop state, unregisters from running.json. 10s hard timeout guarantees exit.
### ao stop
@ -218,6 +219,10 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
- Always loads global config (`~/.agent-orchestrator/config.yaml`) to see all projects — local config only has the cwd project
- Records `LastStopState` with `otherProjects` field for cross-project session restore
### ao update
- For package-manager installs, `ao update` pauses a running AO via `ao stop --yes`, runs the global package update, verifies `ao --version`, then restarts with `ao start --restore` (or `--no-restore` if requested)
- Failed package-manager updates must report that AO was not updated, include actionable remediation, and restart the previous installation if AO was paused
### Dashboard sidebar
- Sidebar always shows sessions from ALL projects regardless of which project page is active
- `useSessionEvents` in Dashboard.tsx is called without project filter — sidebar gets unscoped sessions

View File

@ -1810,6 +1810,46 @@ describe("stop command", () => {
expect(output).toContain("app-orchestrator-3");
});
it("does not show the project picker for no-args ao stop across multiple projects", async () => {
mockConfigRef.current = makeConfig({
"project-1": makeProject({ name: "Project 1", sessionPrefix: "p1" }),
"project-2": makeProject({ name: "Project 2", sessionPrefix: "p2" }),
});
mockSessionManager.list.mockResolvedValue([
{
id: "p1-1",
projectId: "project-1",
status: "working",
activity: "active",
metadata: {},
lastActivityAt: new Date(),
runtimeHandle: { id: "tmux-1" },
},
{
id: "p2-1",
projectId: "project-2",
status: "working",
activity: "active",
metadata: {},
lastActivityAt: new Date(),
runtimeHandle: { id: "tmux-2" },
},
]);
mockSessionManager.kill.mockResolvedValue({ cleaned: true, alreadyTerminated: false });
mockPromptConfirm.mockResolvedValue(true);
await program.parseAsync(["node", "test", "stop"]);
expect(mockPromptSelect).not.toHaveBeenCalledWith(
expect.stringContaining("Choose project to stop"),
expect.anything(),
expect.anything(),
);
expect(mockPromptConfirm).toHaveBeenCalledWith("Stop AO and 2 active session(s)?", false);
expect(mockSessionManager.kill).toHaveBeenCalledWith("p1-1", { purgeOpenCode: false });
expect(mockSessionManager.kill).toHaveBeenCalledWith("p2-1", { purgeOpenCode: false });
});
it("kills the most-recently-active orchestrator when multiple exist", async () => {
mockConfigRef.current = makeConfig({ "my-app": makeProject() });
const now = Date.now();

View File

@ -5,9 +5,7 @@ import { Command } from "commander";
// Mocks
// ---------------------------------------------------------------------------
const {
mockRunRepoScript,
} = vi.hoisted(() => ({
const { mockRunRepoScript } = vi.hoisted(() => ({
mockRunRepoScript: vi.fn(),
}));
@ -55,8 +53,8 @@ vi.mock("../../src/lib/update-check.js", () => ({
isManualOnlyInstall: (m: string) => m === "homebrew",
}));
// Stub the active-session guard's dependencies so handlers don't try to load
// real config / spawn plugins. Default: no sessions, so the guard passes.
// Stub the update lifecycle planner's dependencies so handlers don't try to
// load real config / spawn plugins. Default: no sessions, so no stop is needed.
const { mockSessions } = vi.hoisted(() => ({
mockSessions: { value: [] as Array<{ id: string; status: string }> },
}));
@ -84,8 +82,7 @@ vi.mock("@aoagents/ao-core", async () => {
loadConfig: (...args: unknown[]) => mockLoadConfig(...args),
loadGlobalConfig: (...args: unknown[]) => mockLoadGlobalConfig(...args),
getGlobalConfigPath: () => "/tmp/test-global-config.yaml",
isCanonicalGlobalConfigPath: (p: string | undefined) =>
p === "/tmp/test-global-config.yaml",
isCanonicalGlobalConfigPath: (p: string | undefined) => p === "/tmp/test-global-config.yaml",
isWindows: () => mockIsWindows(),
};
});
@ -98,10 +95,8 @@ vi.mock("node:fs", async () => {
};
});
// running.json is the live signal: ensureNoActiveSessions now consults
// `getRunning()` before falling back to the global registry. Default to
// "no daemon running" so the existing global-config-driven tests keep
// exercising the fallback path. Per-test overrides simulate a live daemon.
// running.json is the live signal for update stop/start orchestration. Default
// to "no daemon running"; per-test overrides simulate a live daemon.
const { mockGetRunning } = vi.hoisted(() => ({
mockGetRunning: vi.fn<() => Promise<unknown>>(async () => null),
}));
@ -147,9 +142,28 @@ function makeNpmUpdateInfo(overrides = {}) {
};
}
function createMockChild(exitCode: number | null, signal?: NodeJS.Signals) {
function createMockChild(
exitCode: number | null,
signal?: NodeJS.Signals,
output: { stdout?: string; stderr?: string } = { stdout: "0.3.0\n" },
) {
const child = new EventEmitter();
setTimeout(() => child.emit("exit", exitCode, signal ?? null), 0);
const stdout = new EventEmitter();
const stderr = new EventEmitter();
Object.assign(child, { stdout, stderr });
const emitResult = () => {
setTimeout(() => {
if (output.stdout) stdout.emit("data", Buffer.from(output.stdout));
if (output.stderr) stderr.emit("data", Buffer.from(output.stderr));
child.emit("exit", exitCode, signal ?? null);
}, 0);
};
const originalOn = child.on.bind(child);
child.on = ((event: string | symbol, listener: (...args: unknown[]) => void) => {
const result = originalOn(event, listener);
if (event === "exit") emitResult();
return result;
}) as EventEmitter["on"];
return child;
}
@ -166,7 +180,9 @@ describe("update command", () => {
mockRunRepoScript.mockResolvedValue(0);
mockDetectInstallMethod.mockReturnValue("git");
mockCheckForUpdate.mockReset();
mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "git", recommendedCommand: "ao update" }));
mockCheckForUpdate.mockResolvedValue(
makeNpmUpdateInfo({ installMethod: "git", recommendedCommand: "ao update" }),
);
mockInvalidateCache.mockReset();
mockPromptConfirm.mockReset();
mockPromptConfirm.mockResolvedValue(false);
@ -234,9 +250,9 @@ describe("update command", () => {
it("rejects --smoke-only on npm installs with an actionable message", async () => {
mockDetectInstallMethod.mockReturnValue("npm-global");
const errSpy = vi.mocked(console.error);
await expect(
program.parseAsync(["node", "test", "update", "--smoke-only"]),
).rejects.toThrow("process.exit(1)");
await expect(program.parseAsync(["node", "test", "update", "--smoke-only"])).rejects.toThrow(
"process.exit(1)",
);
const messages = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(messages).toMatch(/--smoke-only only applies to git installs/);
});
@ -306,9 +322,9 @@ describe("update command", () => {
new Error("Script not found: ao-update.sh. Expected at: /tmp/ao-update.sh"),
);
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
"process.exit(1)",
);
expect(mockSpawn).not.toHaveBeenCalled();
expect(mockCheckForUpdate).not.toHaveBeenCalled();
@ -353,7 +369,9 @@ describe("update command", () => {
});
it("prints already up to date when not outdated", async () => {
mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ isOutdated: false, latestVersion: "0.2.2", currentVersion: "0.2.2" }));
mockCheckForUpdate.mockResolvedValue(
makeNpmUpdateInfo({ isOutdated: false, latestVersion: "0.2.2", currentVersion: "0.2.2" }),
);
const logSpy = vi.mocked(console.log);
await program.parseAsync(["node", "test", "update"]);
@ -365,9 +383,9 @@ describe("update command", () => {
makeNpmUpdateInfo({ latestVersion: null, isOutdated: false }),
);
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
"process.exit(1)",
);
expect(vi.mocked(console.error)).toHaveBeenCalledWith(
expect.stringContaining("Could not reach npm registry"),
);
@ -375,9 +393,7 @@ describe("update command", () => {
it("forces a fresh registry fetch", async () => {
await program.parseAsync(["node", "test", "update"]);
expect(mockCheckForUpdate).toHaveBeenCalledWith(
expect.objectContaining({ force: true }),
);
expect(mockCheckForUpdate).toHaveBeenCalledWith(expect.objectContaining({ force: true }));
});
it("prints command and exits cleanly in non-TTY mode without prompting", async () => {
@ -400,7 +416,11 @@ describe("update command", () => {
await program.parseAsync(["node", "test", "update"]);
expect(mockSpawn).toHaveBeenCalledWith("npm", expect.arrayContaining(["install"]), expect.anything());
expect(mockSpawn).toHaveBeenCalledWith(
"npm",
expect.arrayContaining(["install"]),
expect.anything(),
);
expect(mockInvalidateCache).toHaveBeenCalled();
});
@ -410,9 +430,9 @@ describe("update command", () => {
mockPromptConfirm.mockResolvedValue(true);
mockSpawn.mockReturnValue(createMockChild(1));
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
"process.exit(1)",
);
expect(mockInvalidateCache).not.toHaveBeenCalled();
});
@ -438,9 +458,9 @@ describe("update command", () => {
mockPromptConfirm.mockResolvedValue(true);
mockSpawn.mockReturnValue(createMockChild(null, "SIGTERM"));
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
"process.exit(1)",
);
expect(vi.mocked(console.error)).not.toHaveBeenCalledWith(
expect.stringContaining("exited with code null"),
@ -448,18 +468,26 @@ describe("update command", () => {
expect(mockInvalidateCache).not.toHaveBeenCalled();
});
it("handles spawn error (e.g. npm not found)", async () => {
it("handles spawn error (e.g. npm not found) with a friendly update failure", async () => {
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
mockPromptConfirm.mockResolvedValue(true);
const child = new EventEmitter();
Object.assign(child, { stdout: new EventEmitter(), stderr: new EventEmitter() });
mockSpawn.mockReturnValue(child);
setTimeout(() => child.emit("error", new Error("ENOENT: npm not found")), 0);
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("ENOENT");
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
"process.exit(1)",
);
const stderr = vi
.mocked(console.error)
.mock.calls.map((c) => String(c[0]))
.join("\n");
expect(stderr).toContain("AO was not updated. You are still on version 0.2.2.");
expect(stderr).toContain("npm not found");
});
it("does nothing when user declines prompt", async () => {
@ -489,7 +517,9 @@ describe("update command", () => {
await program.parseAsync(["node", "test", "update"]);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Could not detect install method"));
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining("Could not detect install method"),
);
expect(mockRunRepoScript).not.toHaveBeenCalled();
});
@ -521,269 +551,143 @@ describe("update command", () => {
});
// -----------------------------------------------------------------------
// Active-session guard (Section C)
// Update lifecycle orchestration (#1972)
// -----------------------------------------------------------------------
describe("active-session guard", () => {
describe("update lifecycle orchestration", () => {
beforeEach(() => {
mockDetectInstallMethod.mockReturnValue("npm-global");
mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "npm-global" }));
mockDetectInstallMethod.mockReturnValue("pnpm-global");
mockResolveUpdateChannel.mockReturnValue("stable");
mockGetUpdateCommand.mockImplementation((method: string) => {
if (method === "pnpm-global") return "pnpm add -g @aoagents/ao@latest";
return "npm install -g @aoagents/ao@latest";
});
mockCheckForUpdate.mockResolvedValue(
makeNpmUpdateInfo({
installMethod: "pnpm-global",
recommendedCommand: "pnpm add -g @aoagents/ao@latest",
}),
);
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
// The guard now ALWAYS loads from global config. Stage a registered
// project so the early-return ("no registry → allow") doesn't fire.
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",
}));
mockSessions.value = [{ id: "feat-1", status: "working", projectId: "my-app" }];
});
it("runs stop → package install → version verification → start/restore when sessions are active", async () => {
mockSpawn.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "ao" && args[0] === "--version") {
return createMockChild(0, undefined, { stdout: "0.3.0\n" });
}
return createMockChild(0, undefined, { stdout: "" });
});
await program.parseAsync(["node", "test", "update"]);
expect(mockSpawn).toHaveBeenCalledTimes(4);
expect(mockSpawn.mock.calls[0]).toEqual([
"ao",
["stop", "--yes"],
expect.objectContaining({ stdio: "inherit" }),
]);
expect(mockSpawn.mock.calls[1][0]).toBe("pnpm");
expect(mockSpawn.mock.calls[1][1]).toEqual(["add", "-g", "@aoagents/ao@latest"]);
expect(mockSpawn.mock.calls[2][0]).toBe("ao");
expect(mockSpawn.mock.calls[2][1]).toEqual(["--version"]);
expect(mockSpawn.mock.calls[3]).toEqual([
"ao",
["start", "my-app", "--restore"],
expect.objectContaining({ stdio: "inherit" }),
]);
const stopOrder = mockSpawn.mock.invocationCallOrder[0];
const installOrder = mockSpawn.mock.invocationCallOrder[1];
const startOrder = mockSpawn.mock.invocationCallOrder[3];
expect(stopOrder).toBeLessThan(installOrder);
expect(installOrder).toBeLessThan(startOrder);
});
it("does not stop/start when no daemon or active sessions exist", async () => {
mockGetRunning.mockReset();
mockGetRunning.mockResolvedValue(null);
mockExistsSync.mockReturnValue(false);
mockSessions.value = [];
mockSpawn.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "ao" && args[0] === "--version") {
return createMockChild(0, undefined, { stdout: "0.3.0\n" });
}
return createMockChild(0, undefined, { stdout: "" });
});
await program.parseAsync(["node", "test", "update"]);
expect(mockSpawn).toHaveBeenCalledTimes(2);
expect(mockSpawn.mock.calls[0][0]).toBe("pnpm");
expect(mockSpawn.mock.calls[1][0]).toBe("ao");
expect(mockSpawn.mock.calls[1][1]).toEqual(["--version"]);
});
it("cleans up orphaned active sessions without starting a daemon that was not running", async () => {
mockGetRunning.mockReset();
mockGetRunning.mockResolvedValue(null);
mockExistsSync.mockReturnValue(true);
mockLoadGlobalConfig.mockReturnValue({
projects: { "my-app": { path: "/tmp/foo" } },
});
mockLoadConfig.mockImplementation((path?: string) =>
path
? { projects: { "my-app": { path: "/tmp/foo" } }, configPath: path }
: { projects: { "my-app": { path: "/tmp/foo" } }, configPath: "/cwd/agent-orchestrator.yaml" },
mockSessions.value = [{ id: "orphan-1", status: "working", projectId: "my-app" }];
mockSpawn.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "ao" && args[0] === "stop") {
mockSessions.value = [];
return createMockChild(0, undefined, { stdout: "" });
}
if (cmd === "ao" && args[0] === "--version") {
return createMockChild(0, undefined, { stdout: "0.3.0\n" });
}
return createMockChild(0, undefined, { stdout: "" });
});
await program.parseAsync(["node", "test", "update"]);
expect(mockSpawn.mock.calls[0][0]).toBe("ao");
expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]);
expect(mockSpawn.mock.calls[1][0]).toBe("pnpm");
expect(mockSpawn.mock.calls[2][0]).toBe("ao");
expect(mockSpawn.mock.calls[2][1]).toEqual(["--version"]);
expect(mockSpawn.mock.calls.some((call) => call[0] === "ao" && call[1][0] === "start")).toBe(
false,
);
});
it("refuses to install when a session is in 'working'", async () => {
mockSessions.value = [{ id: "feat-1", status: "working" }];
const errSpy = vi.mocked(console.error);
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
const messages = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(messages).toMatch(/1 session active/);
expect(messages).toMatch(/ao stop/);
expect(mockSpawn).not.toHaveBeenCalled();
});
it.each(["working", "idle", "needs_input", "stuck"])(
"refuses for status %s",
async (status) => {
mockSessions.value = [{ id: "feat-1", status }];
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
},
);
it("does NOT refuse for terminal statuses (done, terminated, killed)", async () => {
mockSessions.value = [
{ id: "old-1", status: "done" },
{ id: "old-2", status: "terminated" },
];
mockPromptConfirm.mockResolvedValue(false); // decline, no install
await program.parseAsync(["node", "test", "update"]);
// Reaches the prompt step since the guard passed.
expect(mockPromptConfirm).toHaveBeenCalled();
});
// ---------------------------------------------------------------------
// Global-config layout (review #3 / scope-gap follow-up)
// ---------------------------------------------------------------------
it("the refusal message lists active sessions from EVERY registered project, not just one (Dhruv proof)", async () => {
// Reviewer challenge: prove loadConfig(globalPath) actually enumerates
// sessions across all registered projects, not just the cwd's project.
// We register proj-a + proj-b in the global config, seed one active
// session in each, and assert BOTH ids appear in the stderr output.
mockLoadConfig.mockImplementation((path?: string) => {
// Mimic buildEffectiveConfigFromGlobalConfigPath: the global path
// returns BOTH projects; project-local would only return one.
if (!path) {
return { projects: { "proj-a": {} }, configPath: "/cwd/agent-orchestrator.yaml" };
it("honors --no-restore when restarting after update", async () => {
mockSpawn.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "ao" && args[0] === "--version") {
return createMockChild(0, undefined, { stdout: "0.3.0\n" });
}
return {
projects: {
"proj-a": { path: "/repos/a" },
"proj-b": { path: "/repos/b" },
},
configPath: path,
};
return createMockChild(0, undefined, { stdout: "" });
});
mockLoadGlobalConfig.mockReturnValue({
projects: {
"proj-a": { path: "/repos/a" },
"proj-b": { path: "/repos/b" },
},
});
mockExistsSync.mockReturnValue(true);
// One active session per project. sm.list() is single-call (the SM
// implementation enumerates across all projectIds), so we return both
// sessions in one shot — matching real behavior. `projectId` is
// included so it's visible to anyone reading the refusal output.
mockSessions.value = [
{ id: "proj-a-feat-1", status: "working", projectId: "proj-a" },
{ id: "proj-b-feat-2", status: "needs_input", projectId: "proj-b" },
];
const errSpy = vi.mocked(console.error);
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
await program.parseAsync(["node", "test", "update", "--no-restore"]);
const stderr = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
// Refusal message reports the correct total count (2, not 1).
expect(stderr).toMatch(/2 sessions active/);
// Both project's session ids appear in the listing.
expect(stderr).toMatch(/proj-a-feat-1/);
expect(stderr).toMatch(/proj-b-feat-2/);
expect(mockSpawn).not.toHaveBeenCalled();
expect(mockSpawn.mock.calls.at(-1)).toEqual([
"ao",
["start", "my-app", "--no-restore"],
expect.objectContaining({ stdio: "inherit" }),
]);
});
it("always loads global config (never project-local), so sessions in OTHER projects fire the guard", async () => {
// Simulate running inside a project: project-local loadConfig() would
// succeed and return only THIS project's sessions. The guard must
// ignore it and still consult the global registry, otherwise active
// sessions in other projects get missed and the install would proceed.
mockLoadConfig.mockImplementation((path?: string) => {
if (!path) {
// Project-local: would return only "this-project"'s sessions.
return { projects: { "this-project": {} }, configPath: "/cwd/agent-orchestrator.yaml" };
}
return {
projects: {
"this-project": { path: "/cwd" },
"other-project": { path: "/other" },
},
configPath: path,
};
});
mockLoadGlobalConfig.mockReturnValue({
projects: {
"this-project": { path: "/cwd" },
"other-project": { path: "/other" },
},
});
mockExistsSync.mockReturnValue(true);
// Active session lives in the OTHER project — only visible via global.
mockSessions.value = [
{ id: "other-1", status: "working" },
];
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
expect(mockLoadGlobalConfig).toHaveBeenCalled();
// Critical: we did NOT call the project-local (no-arg) loadConfig path.
const noArgCalls = mockLoadConfig.mock.calls.filter((c) => c.length === 0);
expect(noArgCalls).toHaveLength(0);
expect(mockSpawn).not.toHaveBeenCalled();
});
it("uses the global registry when running outside any project", async () => {
mockLoadConfig.mockImplementation((path?: string) => {
if (!path) throw new Error("no config found");
return { projects: { "my-app": { path: "/tmp/foo" } }, configPath: path };
});
mockLoadGlobalConfig.mockReturnValue({
projects: { "my-app": { path: "/tmp/foo" } },
});
mockExistsSync.mockReturnValue(true);
mockSessions.value = [{ id: "feat-1", status: "working" }];
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
expect(mockLoadGlobalConfig).toHaveBeenCalled();
expect(mockSpawn).not.toHaveBeenCalled();
});
it("returns early without building SessionManager when global registry is empty", async () => {
mockLoadConfig.mockImplementation((path?: string) => {
if (!path) throw new Error("no config found");
return { projects: {}, configPath: path };
});
mockLoadGlobalConfig.mockReturnValue({ projects: {} });
mockExistsSync.mockReturnValue(true);
// Guard returns true (allow update) without ever calling sm.list().
// No mockSessions configured, no spawn → confirms we never reached
// SessionManager construction.
mockPromptConfirm.mockResolvedValue(false); // decline soft-install
await program.parseAsync(["node", "test", "update"]);
expect(mockLoadGlobalConfig).toHaveBeenCalled();
// The decline-prompt path means the guard let us through.
expect(mockPromptConfirm).toHaveBeenCalled();
});
it("returns early without building SessionManager when global config file is missing", async () => {
mockLoadConfig.mockImplementation((path?: string) => {
if (!path) throw new Error("no config found");
return { projects: {}, configPath: path };
});
mockExistsSync.mockReturnValue(false); // no ~/.agent-orchestrator/config.yaml
mockPromptConfirm.mockResolvedValue(false);
await program.parseAsync(["node", "test", "update"]);
// We didn't even consult loadGlobalConfig — existsSync(globalPath) was false.
expect(mockLoadGlobalConfig).not.toHaveBeenCalled();
expect(mockPromptConfirm).toHaveBeenCalled();
});
it("refuses when sessions exist in a locally-registered project not in global config (Dhruv edge-case)", async () => {
// The bypass: user ran `ao start` from a repo with a local
// agent-orchestrator.yaml and no global registration. running.json
// says that project is being polled, sessions live on disk, but the
// global registry is empty. Before this fix, the guard hit the
// "global has no projects → allow" branch and let `ao update`
// clobber the running daemon.
//
// Fix: consult running.json BEFORE falling back to global. When
// running.json has projects, build the SessionManager from
// running.configPath (which is the local project's yaml in this case)
// and enumerate from there.
mockGetRunning.mockResolvedValue({
pid: 12345,
configPath: "/repos/local-only/agent-orchestrator.yaml",
port: 3000,
startedAt: new Date().toISOString(),
projects: ["local-only"],
});
// Global registry has no record of `local-only` — this is the bypass
// condition. With the old code, we'd return true here.
mockLoadGlobalConfig.mockReturnValue({ projects: {} });
// loadConfig with the local configPath returns the local project's
// OrchestratorConfig (project-local schema is auto-wrapped).
mockLoadConfig.mockImplementation((path?: string) => {
if (path === "/repos/local-only/agent-orchestrator.yaml") {
return {
projects: { "local-only": { path: "/repos/local-only" } },
configPath: path,
};
}
return { projects: {}, configPath: path ?? "/cwd/agent-orchestrator.yaml" };
});
mockSessions.value = [
{ id: "local-feat-1", status: "working", projectId: "local-only" },
];
const errSpy = vi.mocked(console.error);
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
const stderr = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(stderr).toMatch(/1 session active/);
expect(stderr).toMatch(/local-feat-1/);
// We must have routed through running.configPath, NOT the global path.
expect(mockLoadConfig).toHaveBeenCalledWith("/repos/local-only/agent-orchestrator.yaml");
expect(mockSpawn).not.toHaveBeenCalled();
});
it("returns true (allows update) when running.json is gone and global is empty", async () => {
// No daemon running, no global projects. Genuinely safe to update.
mockGetRunning.mockResolvedValue(null);
mockExistsSync.mockReturnValue(false);
mockPromptConfirm.mockResolvedValue(false);
await program.parseAsync(["node", "test", "update"]);
expect(mockPromptConfirm).toHaveBeenCalled(); // guard passed → reached prompt
});
it("trusts running.json over an inconsistent global config", async () => {
// running.json says project P is being polled. Global config also
// lists P. We should use running.configPath (the live signal), and
// any active session in P fires the guard.
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",
@ -791,23 +695,43 @@ describe("update command", () => {
startedAt: new Date().toISOString(),
projects: ["my-app"],
});
mockLoadConfig.mockImplementation((path?: string) => ({
projects: { "my-app": { path: "/tmp/foo" } },
configPath: path ?? "/cwd/agent-orchestrator.yaml",
}));
mockLoadGlobalConfig.mockReturnValue({
projects: { "my-app": { path: "/tmp/foo" } },
});
mockSessions.value = [{ id: "feat-1", status: "working" }];
mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "" }));
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
"process.exit(1)",
);
// Because getRunning() returned a daemon, we went straight to its
// configPath — we should NOT have fallen back to loadGlobalConfig.
expect(mockLoadGlobalConfig).not.toHaveBeenCalled();
expect(mockSpawn).not.toHaveBeenCalled();
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(
createMockChild(1, undefined, {
stderr: "ERR_PNPM_UNEXPECTED_VIRTUAL_STORE Unexpected virtual store location\n",
}),
);
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
"process.exit(1)",
);
const stderr = vi
.mocked(console.error)
.mock.calls.map((c) => String(c[0]))
.join("\n");
expect(stderr).toContain("AO was not updated. You are still on version 0.2.2.");
expect(stderr).toContain("pnpm's global store metadata is inconsistent");
expect(stderr).toContain("You can also try: npm install -g @aoagents/ao@latest");
expect(stderr).toContain("ERR_PNPM_UNEXPECTED_VIRTUAL_STORE");
expect(mockInvalidateCache).not.toHaveBeenCalled();
});
});
@ -874,7 +798,7 @@ describe("update command", () => {
}),
);
mockPromptConfirm.mockResolvedValue(true);
mockSpawn.mockReturnValue(createMockChild(0));
mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "0.5.0-nightly-abc\n" }));
await program.parseAsync(["node", "test", "update"]);
@ -990,7 +914,7 @@ describe("update command", () => {
}),
);
mockPromptConfirm.mockResolvedValue(true);
mockSpawn.mockReturnValue(createMockChild(0));
mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "0.5.0-nightly-abc\n" }));
await program.parseAsync(["node", "test", "update"]);
@ -1035,9 +959,7 @@ describe("update command", () => {
beforeEach(() => {
mockDetectInstallMethod.mockReturnValue("npm-global");
mockResolveUpdateChannel.mockReturnValue("stable");
mockCheckForUpdate.mockResolvedValue(
makeNpmUpdateInfo({ installMethod: "npm-global" }),
);
mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "npm-global" }));
mockExistsSync.mockReturnValue(true);
mockLoadGlobalConfig.mockReturnValue({
projects: { "my-app": { path: "/tmp/foo" } },
@ -1069,7 +991,11 @@ describe("update command", () => {
// would run. Asserting spawn was called proves the install actually
// happens in the API-invoked path.
await program.parseAsync(["node", "test", "update"]);
expect(mockSpawn).toHaveBeenCalledTimes(1);
expect(mockSpawn).toHaveBeenCalledWith(
"npm",
expect.arrayContaining(["install"]),
expect.anything(),
);
// And without a TTY, we MUST NOT have prompted — that would hang the
// detached child forever.
expect(mockPromptConfirm).not.toHaveBeenCalled();
@ -1084,12 +1010,25 @@ describe("update command", () => {
expect(mockSpawn).not.toHaveBeenCalled();
});
it("still refuses on active sessions even when API-invoked (the API's own guard isn't a single point of trust)", async () => {
it("orchestrates stop/install/verify/start when active sessions exist and update is API-invoked", async () => {
mockSessions.value = [{ id: "feat-1", status: "working" }];
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
expect(mockSpawn).not.toHaveBeenCalled();
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"]);
expect(mockSpawn.mock.calls[0][0]).toBe("ao");
expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]);
expect(mockSpawn.mock.calls.at(-1)?.[0]).toBe("ao");
expect(mockSpawn.mock.calls.at(-1)?.[1]).toEqual(["start", "my-app", "--restore"]);
});
});
@ -1128,17 +1067,15 @@ describe("update command", () => {
it("passes shell:true and windowsHide:true on Windows so PATHEXT resolves npm.cmd", async () => {
mockIsWindows.mockReturnValue(true);
await program.parseAsync(["node", "test", "update"]);
expect(mockSpawn).toHaveBeenCalledTimes(1);
const opts = mockSpawn.mock.calls[0][2] as Record<string, unknown>;
expect(opts.shell).toBe(true);
expect(opts.windowsHide).toBe(true);
expect(opts.stdio).toBe("inherit");
expect(opts.stdio).toEqual(["inherit", "pipe", "pipe"]);
});
it("passes shell:false on macOS / Linux (no shell wrap needed)", async () => {
mockIsWindows.mockReturnValue(false);
await program.parseAsync(["node", "test", "update"]);
expect(mockSpawn).toHaveBeenCalledTimes(1);
const opts = mockSpawn.mock.calls[0][2] as Record<string, unknown>;
expect(opts.shell).toBe(false);
});

View File

@ -878,7 +878,14 @@ async function runStartup(
config: OrchestratorConfig,
projectId: string,
project: ProjectConfig,
opts?: { dashboard?: boolean; orchestrator?: boolean; rebuild?: boolean; dev?: boolean },
opts?: {
dashboard?: boolean;
orchestrator?: boolean;
rebuild?: boolean;
dev?: boolean;
/** true = restore without prompting, false = skip restore, undefined = prompt for humans */
restore?: boolean;
},
): Promise<number> {
await runtimePreflight(config);
@ -1011,11 +1018,14 @@ async function runStartup(
}
}
// Check for sessions from last `ao stop` and offer to restore them
if (isHumanCaller()) {
// Check for sessions from last `ao stop` and restore/prompt/skip based on caller intent.
if (opts?.restore !== false && (opts?.restore === true || isHumanCaller())) {
try {
const lastStop = await readLastStop();
if (lastStop && lastStop.sessionIds.length > 0) {
const totalLastStopSessions =
(lastStop?.sessionIds.length ?? 0) +
(lastStop?.otherProjects ?? []).reduce((sum, p) => sum + p.sessionIds.length, 0);
if (lastStop && totalLastStopSessions > 0) {
const stoppedAgo = `stopped at ${new Date(lastStop.stoppedAt).toLocaleString()}`;
const otherProjects = lastStop.otherProjects ?? [];
const restoreProjectBySessionId = new Map<string, string>();
@ -1056,7 +1066,8 @@ async function runStartup(
}
if (allRestoreSessions.length > 0) {
const shouldRestore = await promptConfirm("Restore these sessions?", true);
const shouldRestore =
opts?.restore === true ? true : await promptConfirm("Restore these sessions?", true);
if (shouldRestore) {
recordActivityEvent({
projectId,
@ -1441,6 +1452,8 @@ export function registerStart(program: Command): void {
.option("--dev", "Use Next.js dev server with hot reload (for dashboard UI development)")
.option("--interactive", "Prompt to configure config settings")
.option("--reap-orphans", "Kill orphaned AO child processes before starting")
.option("--restore", "Restore sessions from last ao stop without prompting")
.option("--no-restore", "Skip restoring sessions from last ao stop")
.action(
async (
projectArg?: string,
@ -1451,6 +1464,7 @@ export function registerStart(program: Command): void {
dev?: boolean;
interactive?: boolean;
reapOrphans?: boolean;
restore?: boolean;
},
) => {
recordActivityEvent({
@ -1847,7 +1861,8 @@ export function registerStop(program: Command): void {
.description("Stop orchestrator agent and dashboard")
.option("--purge-session", "Delete mapped OpenCode session when stopping")
.option("--all", "Stop all running AO instances")
.action(async (projectArg?: string, opts: { purgeSession?: boolean; all?: boolean } = {}) => {
.option("-y, --yes", "Confirm stopping active sessions without prompting")
.action(async (projectArg?: string, opts: { purgeSession?: boolean; all?: boolean; yes?: boolean } = {}) => {
recordActivityEvent({
source: "cli",
kind: "cli.stop_invoked",
@ -1896,10 +1911,30 @@ export function registerStop(program: Command): void {
config = loadConfig(globalPath);
}
}
const { projectId: _projectId, project } = await resolveProject(config, projectArg, "stop");
let _projectId: string;
let project: ProjectConfig;
if (projectArg) {
({ projectId: _projectId, project } = await resolveProject(config, projectArg, "stop"));
} else {
const projectIds = Object.keys(config.projects);
if (projectIds.length === 0) {
throw new Error("No projects configured. Add a project to agent-orchestrator.yaml.");
}
const currentDir = resolve(cwd());
const cwdProjectId = findProjectForDirectory(config.projects, currentDir);
_projectId =
running?.projects.find((id) => config.projects[id]) ??
cwdProjectId ??
projectIds[0];
project = config.projects[_projectId];
}
const port = config.port ?? DEFAULT_PORT;
console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`));
if (projectArg) {
console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`));
} else {
console.log(chalk.bold(`\nStopping AO across all projects\n`));
}
const sm = await getSessionManager(config);
try {
@ -1925,6 +1960,16 @@ export function registerStop(program: Command): void {
const otherByProject = new Map<string, string[]>();
if (activeSessions.length > 0) {
if (!projectArg && opts.yes !== true && isHumanCaller()) {
const confirmed = await promptConfirm(
`Stop AO and ${activeSessions.length} active session(s)?`,
false,
);
if (!confirmed) {
console.log(chalk.yellow("Stop cancelled."));
return;
}
}
const spinner = ora(`Stopping ${activeSessions.length} active session(s)`).start();
const purgeOpenCode = opts?.purgeSession === true;
const warnings: string[] = [];

View File

@ -68,12 +68,16 @@ export function registerUpdate(program: Command): void {
.option("--skip-smoke", "Skip smoke tests after rebuilding (git installs only)")
.option("--smoke-only", "Run smoke tests without fetching or rebuilding (git installs only)")
.option("--check", "Print version info as JSON without upgrading")
.option("--no-restore", "Restart AO after updating but do not restore stopped sessions")
.action(
async (opts: { skipSmoke?: boolean; smokeOnly?: boolean; check?: boolean }) => {
async (opts: {
skipSmoke?: boolean;
smokeOnly?: boolean;
check?: boolean;
restore?: boolean;
}) => {
if (opts.skipSmoke && opts.smokeOnly) {
console.error(
"`ao update` does not allow `--skip-smoke` together with `--smoke-only`.",
);
console.error("`ao update` does not allow `--skip-smoke` together with `--smoke-only`.");
process.exit(1);
}
@ -113,7 +117,7 @@ export function registerUpdate(program: Command): void {
case "npm-global":
case "pnpm-global":
case "bun-global":
await handleNpmUpdate(method);
await handleNpmUpdate(method, { restore: opts.restore !== false });
break;
case "unknown":
await handleUnknownUpdate();
@ -133,21 +137,24 @@ async function handleCheck(): Promise<void> {
}
// ---------------------------------------------------------------------------
// Active-session guard
// Update lifecycle planning
// ---------------------------------------------------------------------------
/**
* Refuse to update when the user has live sessions.
*
* Auto-stopping would lose the agent's in-flight context (and potentially
* uncommitted work). The release doc is explicit: refuse, surface the
* `ao stop` command, let the user decide.
*
* Best-effort when no config is reachable (fresh install, broken yaml)
* we skip the guard rather than blocking the update on a missing dependency.
* Best-effort snapshot used by `ao update` to pause and resume AO around the
* package-manager install. Missing/broken config should not block an update;
* in that case we proceed without attempting a stop/start round trip.
*/
async function ensureNoActiveSessions(): Promise<boolean> {
interface UpdateLifecyclePlan {
runningBeforeUpdate: boolean;
primaryProjectId?: string;
activeSessions: Session[];
}
async function getUpdateLifecyclePlan(): Promise<UpdateLifecyclePlan> {
let sessions: Session[];
let primaryProjectId: string | undefined;
let runningBeforeUpdate = false;
try {
// Live signal first: running.json lists whichever projects the active
// `ao start` daemon is currently polling. That can include local-only
@ -160,6 +167,8 @@ async function ensureNoActiveSessions(): Promise<boolean> {
// truth for "which sessions does the running ao instance own?"
const running = await getRunning();
if (running && running.projects.length > 0) {
runningBeforeUpdate = true;
primaryProjectId = running.projects[0];
// running.configPath could be local-wrapped (a project's
// agent-orchestrator.yaml) OR the canonical global path. loadConfig
// dispatches based on the path shape — both cases produce a full
@ -174,15 +183,18 @@ async function ensureNoActiveSessions(): Promise<boolean> {
// SessionManager's enrichment will reconcile any stale-runtime
// sessions to `killed`, so terminal statuses don't block the update.
const globalPath = getGlobalConfigPath();
if (!existsSync(globalPath)) return true;
if (!existsSync(globalPath)) {
return { runningBeforeUpdate, primaryProjectId, activeSessions: [] };
}
const globalConfig = loadGlobalConfig(globalPath);
if (!globalConfig || Object.keys(globalConfig.projects).length === 0) {
return true;
return { runningBeforeUpdate, primaryProjectId, activeSessions: [] };
}
if (!isCanonicalGlobalConfigPath(globalPath)) {
return true;
return { runningBeforeUpdate, primaryProjectId, activeSessions: [] };
}
const config = loadConfig(globalPath);
primaryProjectId = Object.keys(config.projects)[0];
const sm = await getSessionManager(config);
sessions = await sm.list();
}
@ -190,29 +202,126 @@ async function ensureNoActiveSessions(): Promise<boolean> {
// If we can't enumerate sessions, don't pretend there are zero — but
// also don't block the upgrade indefinitely. Surface a soft warning.
console.error(
chalk.yellow(
"⚠ Could not check for active sessions before updating. Proceeding anyway.",
),
chalk.yellow("⚠ Could not check for active sessions before updating. Proceeding anyway."),
);
return true;
return { runningBeforeUpdate, primaryProjectId, activeSessions: [] };
}
const active = sessions.filter((s) => ACTIVE_SESSION_STATUSES.has(s.status));
if (active.length === 0) return true;
return { runningBeforeUpdate, primaryProjectId, activeSessions: active };
}
const noun = active.length === 1 ? "session" : "sessions";
console.error(
chalk.red(
`\n✗ ${active.length} ${noun} active. Run \`ao stop\` first, then \`ao update\`.\n`,
),
);
for (const s of active.slice(0, 5)) {
console.error(chalk.dim(`${s.id} (${s.status})`));
async function pauseAoForUpdate(plan: UpdateLifecyclePlan): Promise<boolean> {
const shouldStop = plan.runningBeforeUpdate || plan.activeSessions.length > 0;
if (!shouldStop) return false;
if (plan.activeSessions.length > 0) {
const noun = plan.activeSessions.length === 1 ? "session" : "sessions";
console.log(
chalk.yellow(
`\n${plan.activeSessions.length} active ${noun} will be paused and restored after the update.`,
),
);
for (const s of plan.activeSessions.slice(0, 5)) {
console.log(chalk.dim(`${s.id} (${s.status})`));
}
if (plan.activeSessions.length > 5) {
console.log(chalk.dim(` … and ${plan.activeSessions.length - 5} more`));
}
} else {
console.log(chalk.dim("\nAO is running; it will be restarted after the update."));
}
if (active.length > 5) {
console.error(chalk.dim(` … and ${active.length - 5} more`));
const stopExit = await runAoLifecycleCommand(["stop", "--yes"]);
if (stopExit !== 0) {
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
level: "error",
summary: `ao update failed: internal ao stop exited non-zero`,
data: { exitCode: stopExit },
});
console.error(chalk.red(`\nAO update could not stop the running daemon (exit ${stopExit}).`));
process.exit(stopExit);
}
return false;
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 plan.runningBeforeUpdate;
}
async function restartAoAfterUpdate(
plan: UpdateLifecyclePlan,
opts: { restore: boolean },
): Promise<void> {
const args = ["start"];
if (plan.primaryProjectId) args.push(plan.primaryProjectId);
args.push(opts.restore ? "--restore" : "--no-restore");
console.log(chalk.dim(`\nRestarting AO: ao ${args.join(" ")}`));
const exitCode = await runAoLifecycleCommand(args);
if (exitCode !== 0) {
recordActivityEvent({
source: "cli",
kind: "cli.update_restart_failed",
level: "error",
summary: `ao update could not restart AO after install`,
data: { exitCode, args },
});
console.error(
chalk.yellow(
`\nAO was updated, but \`ao ${args.join(" ")}\` failed with exit ${exitCode}. ` +
`Run it manually to restore your sessions.`,
),
);
process.exit(exitCode);
}
}
function runAoLifecycleCommand(args: string[]): Promise<number> {
return new Promise<number>((resolveExit) => {
const child = spawn("ao", args, {
stdio: "inherit",
shell: isWindows(),
windowsHide: true,
});
child.on("error", (error) => {
console.error(chalk.yellow(`Could not run ao ${args.join(" ")}: ${error.message}`));
resolveExit(1);
});
child.on("exit", (code, signal) => {
resolveExit(signal ? 1 : (code ?? 1));
});
});
}
// ---------------------------------------------------------------------------
@ -222,10 +331,10 @@ async function ensureNoActiveSessions(): Promise<boolean> {
async function handleGitUpdate(opts: {
skipSmoke?: boolean;
smokeOnly?: boolean;
restore?: boolean;
}): Promise<void> {
if (!(await ensureNoActiveSessions())) {
process.exit(1);
}
const lifecyclePlan = await getUpdateLifecyclePlan();
const shouldRestart = await pauseAoForUpdate(lifecyclePlan);
const args: string[] = [];
if (opts.skipSmoke) args.push("--skip-smoke");
@ -241,14 +350,17 @@ async function handleGitUpdate(opts: {
summary: `ao update (git) failed: ao-update.sh exited non-zero`,
data: { method: "git", exitCode },
});
if (shouldRestart) {
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
}
process.exit(exitCode);
}
invalidateCache();
if (shouldRestart) {
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
}
} catch (error) {
if (
error instanceof Error &&
error.message.includes("Script not found: ao-update.sh")
) {
if (error instanceof Error && error.message.includes("Script not found: ao-update.sh")) {
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
@ -263,6 +375,9 @@ async function handleGitUpdate(opts: {
"If you're on a package install, reinstall the package.",
),
);
if (shouldRestart) {
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
}
process.exit(1);
}
@ -277,6 +392,9 @@ async function handleGitUpdate(opts: {
},
});
console.error(error instanceof Error ? error.message : String(error));
if (shouldRestart) {
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
}
process.exit(1);
}
}
@ -285,7 +403,7 @@ async function handleGitUpdate(opts: {
// npm / pnpm / bun global install
// ---------------------------------------------------------------------------
async function handleNpmUpdate(method: InstallMethod): Promise<void> {
async function handleNpmUpdate(method: InstallMethod, opts: { restore: boolean }): Promise<void> {
const channel = resolveUpdateChannel();
// Snapshot the previously cached channel BEFORE we force a refresh, so we
@ -332,9 +450,7 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
// is the right UX for a channel transition, and the install command we'd
// run is genuinely different even if the version-compare says "no".
const isChannelSwitch =
!info.isOutdated &&
previousChannel !== undefined &&
previousChannel !== channel;
!info.isOutdated && previousChannel !== undefined && previousChannel !== channel;
// First-channel opt-in. previousChannel === undefined means we've never
// installed via the auto-updater. A user who just ran `ao config set
@ -363,9 +479,7 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
console.log(`Channel: ${chalk.cyan(channel)}`);
if (isChannelSwitch) {
console.log(
chalk.yellow(
`\nChannel switch detected: was on ${previousChannel}, now ${channel}.`,
),
chalk.yellow(`\nChannel switch detected: was on ${previousChannel}, now ${channel}.`),
);
console.log(
chalk.dim(
@ -384,15 +498,12 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
const command = getUpdateCommand(method, channel);
const apiInvoked = isApiInvoked();
const interactive = isTTY() && !apiInvoked;
const lifecyclePlan = await getUpdateLifecyclePlan();
// Non-interactive path: API-invoked OR piped output. We still gate on the
// active-session guard (refusing returns true/false), but we never bail
// out just because there's no terminal — the dashboard's "Update" click
// must actually install. The only thing we skip is the confirm prompt.
if (!(await ensureNoActiveSessions())) {
process.exit(1);
}
// Non-interactive path: API-invoked OR piped output. We still plan the
// stop/start lifecycle, but we never bail out just because there's no
// terminal — the dashboard's "Update" click must actually install. The
// only thing we skip is the confirm prompt.
if (interactive) {
// Soft auto-install: when the user has opted into stable or nightly we
// skip the confirm prompt — they've already said "keep me on this channel."
@ -404,10 +515,7 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
isChannelSwitch || isFirstChannelOptIn
? `Switch to ${channel} via ${chalk.cyan(command)}?`
: `Run ${chalk.cyan(command)}?`;
const confirmed = await promptConfirm(
promptText,
!(isChannelSwitch || isFirstChannelOptIn),
);
const confirmed = await promptConfirm(promptText, !(isChannelSwitch || isFirstChannelOptIn));
if (!confirmed) return;
} else {
console.log(chalk.dim(`Updating: ${command}`));
@ -422,25 +530,95 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
return;
}
const exitCode = await runNpmInstall(command);
if (exitCode === 0) {
invalidateCache();
console.log(chalk.green("\nUpdate complete."));
} else {
const shouldRestart = await pauseAoForUpdate(lifecyclePlan);
const installResult = await runNpmInstall(command);
if (installResult.exitCode !== 0) {
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
level: "error",
summary: `ao update (${method}) failed: install command exited non-zero`,
data: { method, command, exitCode },
data: {
method,
command,
exitCode: installResult.exitCode,
classification: classifyInstallFailure(installResult.output).kind,
},
});
process.exit(exitCode);
printInstallFailure({
method,
command,
channel,
currentVersion: info.currentVersion,
exitCode: installResult.exitCode,
output: installResult.output,
});
if (shouldRestart) {
console.log(chalk.dim("\nRestarting AO with the existing installation..."));
await restartAoAfterUpdate(lifecyclePlan, opts);
}
process.exit(1);
}
const verification = await verifyInstalledVersion(info.latestVersion, info.currentVersion);
if (!verification.ok) {
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
level: "error",
summary: `ao update (${method}) failed: installed version verification failed`,
data: {
method,
command,
expectedVersion: info.latestVersion,
actualVersion: verification.actualVersion,
output: verification.output,
},
});
console.error(chalk.red(`\nAO was not verified after install.`));
console.error(chalk.yellow(verification.message));
console.error(chalk.dim(`Expected: ${info.latestVersion}`));
console.error(chalk.dim(`Current before update: ${info.currentVersion}`));
if (shouldRestart) {
console.log(chalk.dim("\nRestarting AO before exiting..."));
await restartAoAfterUpdate(lifecyclePlan, opts);
}
process.exit(1);
}
invalidateCache();
if (shouldRestart) {
await restartAoAfterUpdate(lifecyclePlan, opts);
}
console.log(
chalk.green(
`\nUpdate complete: ${info.currentVersion}${verification.actualVersion}.` +
(shouldRestart ? " AO restarted." : ""),
),
);
}
function runNpmInstall(command: string): Promise<number> {
interface CommandResult {
exitCode: number;
output: string;
}
function runNpmInstall(command: string): Promise<CommandResult> {
const [cmd, ...args] = command.split(" ");
return new Promise<number>((resolveExit, reject) => {
return runCommandCapture(cmd!, args, { echo: true }).then((result) => {
if (result.exitCode !== 0) {
console.error(chalk.yellow(`\n${cmd} exited with code ${result.exitCode}.`));
}
return result;
});
}
function runCommandCapture(
cmd: string,
args: string[],
opts: { echo?: boolean } = {},
): Promise<CommandResult> {
return new Promise<CommandResult>((resolveExit) => {
// `shell: isWindows()` is required so PATHEXT gets consulted on Windows —
// npm/pnpm/bun install as `*.cmd` shims, and Node.js does not look at
// PATHEXT for non-shell spawns, so a bare `npm` / `pnpm` / `bun` lookup
@ -448,25 +626,162 @@ function runNpmInstall(command: string): Promise<number> {
// keeps the shell window from flashing. Same fix that landed for the
// dashboard's /api/update spawn in commit 9f29131d.
const child = spawn(cmd!, args, {
stdio: "inherit",
stdio: ["inherit", "pipe", "pipe"],
shell: isWindows(),
windowsHide: true,
});
child.on("error", reject);
let output = "";
const collect = (chunk: Buffer | string, stream: NodeJS.WriteStream): void => {
const text = chunk.toString();
output += text;
if (opts.echo) stream.write(chunk);
};
child.stdout?.on("data", (chunk: Buffer | string) => collect(chunk, process.stdout));
child.stderr?.on("data", (chunk: Buffer | string) => collect(chunk, process.stderr));
child.on("error", (error) => {
output += `${error.name}: ${error.message}`;
resolveExit({ exitCode: 1, output });
});
child.on("exit", (code, signal) => {
if (signal) {
resolveExit(1);
resolveExit({ exitCode: 1, output: `${output}\nTerminated by signal ${signal}` });
return;
}
if (code !== 0) {
console.error(chalk.yellow(`\n${cmd} exited with code ${code}.`));
}
resolveExit(code ?? 1);
resolveExit({ exitCode: code ?? 1, output });
});
});
}
interface VerificationResult {
ok: boolean;
actualVersion?: string;
output: string;
message: string;
}
async function verifyInstalledVersion(
expectedVersion: string,
previousVersion: string,
): Promise<VerificationResult> {
const result = await runCommandCapture("ao", ["--version"]);
const output = result.output.trim();
const actualVersion = parseAoVersion(output);
if (result.exitCode !== 0) {
return {
ok: false,
output,
message: `\`ao --version\` failed with exit ${result.exitCode}.`,
};
}
if (!actualVersion) {
return {
ok: false,
output,
message: `Could not parse \`ao --version\` output: ${output || "<empty>"}`,
};
}
if (actualVersion !== expectedVersion) {
return {
ok: false,
actualVersion,
output,
message:
actualVersion === previousVersion
? `The install command exited successfully, but AO is still on ${previousVersion}.`
: `The install command exited successfully, but AO reports ${actualVersion}.`,
};
}
return { ok: true, actualVersion, output, message: "verified" };
}
function parseAoVersion(output: string): string | undefined {
const match = output.match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/);
return match?.[1];
}
function classifyInstallFailure(output: string): { kind: string; guidance: string } {
if (/ERR_PNPM_UNEXPECTED_VIRTUAL_STORE/i.test(output)) {
return {
kind: "pnpm_virtual_store",
guidance:
"pnpm's global store metadata is inconsistent. Try `pnpm store prune`, then retry `ao update`. " +
"If pnpm remains stuck, use the npm fallback below.",
};
}
if (/(?:EACCES|EPERM|permission denied|access denied)/i.test(output)) {
return {
kind: "permission",
guidance:
"The package manager could not write to the global install location. Fix your npm/pnpm global prefix permissions, or retry from a shell with access to that directory.",
};
}
if (/(?:ENETUNREACH|ECONNRESET|ETIMEDOUT|EAI_AGAIN|network|socket hang up)/i.test(output)) {
return {
kind: "network",
guidance:
"The registry request failed due to a network error. Check connectivity/VPN/proxy settings and retry `ao update`.",
};
}
if (
/(?:lockfile|ERR_PNPM_LOCKFILE|ERR_PNPM_OUTDATED_LOCKFILE|ERR_PNPM_BROKEN_LOCKFILE)/i.test(
output,
)
) {
return {
kind: "lockfile",
guidance:
"pnpm reported lockfile state problems. Clear the affected global install metadata or retry with the npm fallback below.",
};
}
if (
/(?:registry|ERR_PNPM_FETCH|ERR_PNPM_META_FETCH_FAIL|E401|E403|E404|404 Not Found|401 Unauthorized|403 Forbidden)/i.test(
output,
)
) {
return {
kind: "registry",
guidance:
"The npm registry rejected or failed the package request. Check registry configuration, auth tokens, and the selected AO update channel.",
};
}
return {
kind: "unknown",
guidance:
"The package manager failed before AO could verify the new version. Retry `ao update` after addressing the package-manager error below.",
};
}
function printInstallFailure(opts: {
method: InstallMethod;
command: string;
channel: ReturnType<typeof resolveUpdateChannel>;
currentVersion: string;
exitCode: number;
output: string;
}): void {
const classification = classifyInstallFailure(opts.output);
const fallbackCommand = getUpdateCommand("npm-global", opts.channel);
console.error(
chalk.red(`\nAO was not updated. You are still on version ${opts.currentVersion}.`),
);
console.error(
chalk.yellow(
`The package manager (${opts.method.replace("-global", "")}) failed with exit ${opts.exitCode}.`,
),
);
console.error(chalk.yellow(classification.guidance));
console.error(chalk.dim(`\nTo retry: ao update`));
if (opts.command !== fallbackCommand) {
console.error(chalk.dim(`You can also try: ${fallbackCommand}`));
}
console.error(chalk.dim("\nPackage manager output:"));
console.error(opts.output.trim() || "<no output>");
}
// ---------------------------------------------------------------------------
// homebrew install (notice only)
// ---------------------------------------------------------------------------
@ -480,9 +795,7 @@ async function handleHomebrewUpdate(): Promise<void> {
console.log(`Latest version: ${chalk.green(info.latestVersion)}`);
}
console.log();
console.log(
`Homebrew installs are managed by brew. Run:\n ${chalk.cyan("brew upgrade ao")}`,
);
console.log(`Homebrew installs are managed by brew. Run:\n ${chalk.cyan("brew upgrade ao")}`);
console.log(
chalk.dim(
" (AO does not auto-install for brew installs because it would clobber brew's symlinks.)",