From 9bfd7656bb4df7b894bca0325c2d25cd5636f348 Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Fri, 8 May 2026 18:13:42 +0530 Subject: [PATCH] fix(cli): refuse to spawn when daemon is not polling the project (#1460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): refuse to spawn when daemon is not polling the project `ao spawn` and `ao batch-spawn` used to print a stderr warning and then create the session anyway when the running AO daemon did not include the target project in its polling set (or when no daemon was running at all). The resulting sessions got full worktrees and tmux panes but no lifecycle reactions — CI-failure routing, review comments, revive transitions, and the event log were silently dead. Promote the warning to a hard error so sessions are never created in a state where the lifecycle manager won't run for them. The error message tells the user which `ao start` invocation will fix it. Closes #1455 * test(cli): cover batch-spawn daemon-polling enforcement `spawn` and `batch-spawn` share the `ensureAOPollingProject` helper, but only `spawn` had tests for the new fail-fast behavior. Add matching tests for `batch-spawn` so a future refactor that breaks its guard is caught. --------- Co-authored-by: Prateek --- packages/cli/__tests__/commands/spawn.test.ts | 105 +++++++++++++++++- packages/cli/src/commands/spawn.ts | 30 ++--- 2 files changed, 118 insertions(+), 17 deletions(-) diff --git a/packages/cli/__tests__/commands/spawn.test.ts b/packages/cli/__tests__/commands/spawn.test.ts index 4f149a7ed..60ffeb6c7 100644 --- a/packages/cli/__tests__/commands/spawn.test.ts +++ b/packages/cli/__tests__/commands/spawn.test.ts @@ -230,6 +230,13 @@ describe("spawn command", () => { mkdirSync(backendSubdir, { recursive: true }); cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(backendSubdir); + mockGetRunning.mockResolvedValue({ + pid: 1234, + port: 3000, + startedAt: "", + projects: ["backend", "frontend"], + }); + const fakeSession: Session = { id: "be-1", projectId: "backend", @@ -284,7 +291,7 @@ describe("spawn command", () => { pid: 1234, port: 3000, startedAt: "", - projects: ["agent-orchestrator"], + projects: ["agent-orchestrator", "x402-identity"], }); const fakeSession: Session = { @@ -336,7 +343,7 @@ describe("spawn command", () => { pid: 1234, port: 3000, startedAt: "", - projects: ["agent-orchestrator"], + projects: ["agent-orchestrator", "x402-identity"], }); const fakeSession: Session = { @@ -947,6 +954,12 @@ describe("batch-spawn command", () => { }; mkdirSync(join(tmpDir, "agent-orchestrator"), { recursive: true }); mkdirSync(join(tmpDir, "x402-identity"), { recursive: true }); + mockGetRunning.mockResolvedValue({ + pid: 1234, + port: 3000, + startedAt: "", + projects: ["agent-orchestrator", "x402-identity"], + }); // Pre-existing active session in x402-identity for issue 20 mockSessionManager.list.mockImplementation(async (pid: string) => { @@ -983,3 +996,91 @@ describe("batch-spawn command", () => { }); }); }); + +describe("spawn daemon-polling enforcement", () => { + it("refuses to spawn when no AO daemon is running", async () => { + mockGetRunning.mockResolvedValue(null); + + await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow( + "process.exit(1)", + ); + + const errors = vi + .mocked(console.error) + .mock.calls.map((c) => String(c[0])) + .join("\n"); + expect(errors).toContain("AO is not running"); + expect(errors).toContain("ao start"); + expect(mockSessionManager.spawn).not.toHaveBeenCalled(); + }); + + it("refuses to spawn when the running daemon is not polling the project", async () => { + mockGetRunning.mockResolvedValue({ + pid: 99999, + port: 3000, + startedAt: "", + projects: ["other-project"], + }); + + await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow( + "process.exit(1)", + ); + + const errors = vi + .mocked(console.error) + .mock.calls.map((c) => String(c[0])) + .join("\n"); + expect(errors).toContain("not polling project"); + expect(errors).toContain("my-app"); + expect(errors).toContain("ao start my-app"); + expect(mockSessionManager.spawn).not.toHaveBeenCalled(); + }); +}); + +describe("batch-spawn daemon-polling enforcement", () => { + let batchProgram: Command; + + beforeEach(() => { + batchProgram = new Command(); + batchProgram.exitOverride(); + registerBatchSpawn(batchProgram); + }); + + it("refuses to batch-spawn when no AO daemon is running", async () => { + mockGetRunning.mockResolvedValue(null); + + await expect( + batchProgram.parseAsync(["node", "test", "batch-spawn", "INT-1", "INT-2"]), + ).rejects.toThrow("process.exit(1)"); + + const errors = vi + .mocked(console.error) + .mock.calls.map((c) => String(c[0])) + .join("\n"); + expect(errors).toContain("AO is not running"); + expect(errors).toContain("ao start"); + expect(mockSessionManager.spawn).not.toHaveBeenCalled(); + }); + + it("refuses to batch-spawn when the running daemon is not polling the project", async () => { + mockGetRunning.mockResolvedValue({ + pid: 99999, + port: 3000, + startedAt: "", + projects: ["other-project"], + }); + + await expect( + batchProgram.parseAsync(["node", "test", "batch-spawn", "INT-1", "INT-2"]), + ).rejects.toThrow("process.exit(1)"); + + const errors = vi + .mocked(console.error) + .mock.calls.map((c) => String(c[0])) + .join("\n"); + expect(errors).toContain("not polling project"); + expect(errors).toContain("my-app"); + expect(errors).toContain("ao start my-app"); + expect(mockSessionManager.spawn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index 061d6f9e8..fed2f0585 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -102,25 +102,25 @@ interface SpawnClaimOptions { /** * Lifecycle polling runs in-process inside the long-lived `ao start` process. * `ao spawn` is a one-shot CLI — it can't start polling in its own process - * (the interval would keep the CLI alive forever and duplicate work). Warn - * when no `ao start` is running, or when the running instance isn't covering - * this project (e.g. `ao start A` then `ao spawn` in B). + * (the interval would keep the CLI alive forever and duplicate work). + * + * Refuse to spawn if no `ao start` is running, or if the running instance is + * not polling this project. Without an active daemon, sessions get worktrees + * and tmux panes but no lifecycle reactions (CI-failure routing, review + * comments, revive transitions, event log). That silent blackout is a + * worse failure mode than creating no session at all — so fail fast with + * an actionable error. */ -async function warnIfAONotRunning(projectId: string): Promise { +async function ensureAOPollingProject(projectId: string): Promise { const running = await getRunning(); if (!running) { - console.log( - chalk.yellow( - "⚠ AO is not running — lifecycle polling is inactive. Run `ao start` so the new session is tracked.", - ), + throw new Error( + `AO is not running — lifecycle polling is inactive. Run \`ao start\` before spawning sessions so they get CI/review routing and state advancement.`, ); - return; } if (!running.projects.includes(projectId)) { - console.log( - chalk.yellow( - `⚠ The running AO instance (pid ${running.pid}) is not polling project "${projectId}" yet. Lifecycle polling will attach within ~60s.`, - ), + throw new Error( + `The running AO instance (pid ${running.pid}) is not polling project "${projectId}". Run \`ao start ${projectId}\` before spawning so sessions get tracked.`, ); } } @@ -325,7 +325,7 @@ export function registerSpawn(program: Command): void { try { await runSpawnPreflight(config, projectId, claimOptions); - await warnIfAONotRunning(projectId); + await ensureAOPollingProject(projectId); await spawnSession(config, projectId, issueId, opts.open, opts.agent, claimOptions, opts.prompt); } catch (err) { @@ -402,7 +402,7 @@ export function registerBatchSpawn(program: Command): void { // Pre-flight once per project group so a missing prerequisite fails fast. try { await runSpawnPreflight(config, groupProjectId); - await warnIfAONotRunning(groupProjectId); + await ensureAOPollingProject(groupProjectId); } catch (err) { console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`)); process.exit(1);