fix(cli): refuse to spawn when daemon is not polling the project (#1460)

* 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 <karnalprateek@gmail.com>
This commit is contained in:
i-trytoohard 2026-05-08 18:13:42 +05:30 committed by GitHub
parent 1981d471ca
commit 9bfd7656bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 118 additions and 17 deletions

View File

@ -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();
});
});

View File

@ -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<void> {
async function ensureAOPollingProject(projectId: string): Promise<void> {
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);