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);