diff --git a/src/handlers/project/dev/index.test.ts b/src/handlers/project/dev/index.test.ts index 461d83ffe..a127f511d 100644 --- a/src/handlers/project/dev/index.test.ts +++ b/src/handlers/project/dev/index.test.ts @@ -4,6 +4,7 @@ import type { ProjectRuntime } from "../../../projectSchemas/runtime"; import { InputValidationError, ResourceNotFoundError, + SilentCLIError, UserCancellationError, } from "../../../errors"; import type { HttpRequestHandler, PortChecker } from "../../../io"; @@ -45,6 +46,22 @@ function captureRunner(events: DevEvent[] = []) { return { runner, inputs }; } +/** A runner that emits `events` then stays alive until aborted, like a real dev server. */ +function stayingRunner(events: DevEvent[] = []) { + const inputs: DevServerInput[] = []; + const runner: DevRunner = { + run: async function* (input) { + inputs.push(input); + yield* events; + if (input.signal.aborted) return; + await new Promise((resolve) => + input.signal.addEventListener("abort", () => resolve(), { once: true }), + ); + }, + }; + return { runner, inputs }; +} + function fakeCollector() { const starts: Parameters[0][] = []; const state = { closed: 0 }; @@ -120,6 +137,9 @@ function harness(options: HarnessOptions = {}) { resolve: async () => options.reloadedRuntimes ? project(...options.reloadedRuntimes) : undefined, }, + waitReady: async () => { + await Bun.sleep(5); + }, }); const ctx = ValueContext.EmptyContext() .withValue(ProjectKey, options.project ?? project(runtime())) @@ -167,16 +187,10 @@ async function inspectorStatus(subject: ReturnType): Promise<{ n describe("project dev selection and dispatch", () => { test.each([ [project(), {}, "This project has no runtimes", InputValidationError], - [ - project(runtime("orders")), - {}, - "--mode headless runs a single agent in the terminal. Pass --agent to choose which one. Available: orders", - InputValidationError, - ], [ project(runtime("orders"), runtime("support", "Container")), - {}, - "--mode headless runs a single agent in the terminal. Pass --agent to choose which one. Available: orders, support", + { port: 4567 }, + "--port applies to a single runtime. Use --agent to select one.", InputValidationError, ], [ @@ -237,6 +251,64 @@ describe("project dev selection and dispatch", () => { }); }); +describe("project dev headless multi-agent", () => { + const twoRuntimes = () => project(runtime("orders"), runtime("support", "Container")); + + /** Start a headless multi-agent run and give its agents time to reach "running". */ + async function supervised(subject: ReturnType) { + const pending = subject.run(); + pending.catch(() => undefined); + await Bun.sleep(30); + return { pending }; + } + + test("supervises every runtime with attributed output and per-runtime env", async () => { + const codeZip = stayingRunner([{ type: "stdout", line: "orders says hi" }]); + const container = stayingRunner(); + const subject = harness({ project: twoRuntimes(), codeZip, container }); + const { pending } = await supervised(subject); + + expect(codeZip.inputs).toHaveLength(1); + expect(container.inputs).toHaveLength(1); + expect(codeZip.inputs[0]!.env).toMatchObject({ + OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:43180", + OTEL_SERVICE_NAME: "orders", + }); + expect(container.inputs[0]!.env).toMatchObject({ + OTEL_EXPORTER_OTLP_ENDPOINT: "http://host.docker.internal:43180", + OTEL_SERVICE_NAME: "support", + }); + expect(subject.io.stdout()).toContain("[orders] orders says hi"); + expect(subject.io.stderr()).toContain("Agent 'orders' is running on port"); + + process.emit("SIGINT", "SIGINT"); + await expect(pending).rejects.toMatchObject({ exitCode: 130 }); + expect(subject.collector.state.closed).toBe(1); + }); + + test("one agent failing to start leaves the others running", async () => { + const subject = harness({ + project: twoRuntimes(), + codeZip: captureRunner([{ type: "status", message: "dying" }]), + container: stayingRunner(), + }); + const { pending } = await supervised(subject); + + expect(subject.io.stderr()).toContain("[orders] Agent 'orders' failed to start"); + expect(subject.io.stderr()).toContain("Agent 'support' is running on port"); + + process.emit("SIGINT", "SIGINT"); + await pending.catch(() => undefined); + }); + + test("exits non-zero when every agent fails to start", async () => { + const subject = harness({ project: twoRuntimes() }); + + await expect(subject.run()).rejects.toBeInstanceOf(SilentCLIError); + expect(subject.collector.state.closed).toBe(1); + }); +}); + describe("project dev trace collection", () => { test("starts the collector, announces it, and points a CodeZip agent at loopback", async () => { const subject = harness(); @@ -396,15 +468,6 @@ describe("project dev Inspector UI mode", () => { "Port 9999 is already in use", ); }); - - test("--port with several runtimes is rejected", async () => { - const subject = harness({ - project: project(runtime("orders"), runtime("support", "Container")), - }); - await expect(subject.run({ mode: "browser", port: 4567 })).rejects.toThrow( - "--port applies to a single runtime", - ); - }); }); test("project dev renders attributed human and NDJSON output", async () => { diff --git a/src/handlers/project/dev/index.ts b/src/handlers/project/dev/index.ts index c21d1a2cb..db0b9d65f 100644 --- a/src/handlers/project/dev/index.ts +++ b/src/handlers/project/dev/index.ts @@ -10,6 +10,7 @@ import type { ProjectRuntime } from "../../../projectSchemas/runtime"; import { InputValidationError, ResourceNotFoundError, + SilentCLIError, UserCancellationError, } from "../../../errors"; import type { AppIO, BrowserOpener, FileWatcher, PortChecker, startHttpServer } from "../../../io"; @@ -102,7 +103,7 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => flag("traces", "disable local OTEL trace collection", z.boolean().default(true)), flag( "mode", - "how to run: browser (Agent Inspector web UI), headless (one agent in the terminal), or tui", + "how to run: browser (Agent Inspector web UI), headless (agents stream to the terminal), or tui", z.enum(["browser", "headless", "tui"]).default("browser"), ), flag( @@ -132,12 +133,6 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => ); } const runtimes = selectRuntimes(project, flags.agent); - if (flags.mode === "headless" && !flags.agent) { - const available = runtimes.map((runtime) => runtime.name).join(", "); - throw new InputValidationError( - `--mode headless runs a single agent in the terminal. Pass --agent to choose which one. Available: ${available}.`, - ); - } if (runtimes.length > 1 && flags.port !== undefined) { throw new InputValidationError( "--port applies to a single runtime. Use --agent to select one.", @@ -194,7 +189,7 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => return { ...env, ...otel }; }; - if (flags.mode === "headless") { + if (flags.mode === "headless" && flags.agent) { await runWithoutUi( config, runtimes[0]!, @@ -227,6 +222,23 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => signal: controller.signal, }); + if (flags.mode === "headless") { + const starts = Promise.allSettled( + runtimes.map((runtime) => supervisor.start(runtime.name)), + ); + for await (const { agentName, event } of supervisor.events()) { + renderAgentEvent(config.io, event, agentName, json); + const phases = supervisor.snapshot(); + if (phases.every(({ phase }) => phase !== "starting" && phase !== "running")) { + if (phases.some(({ phase }) => phase === "failed")) throw new SilentCLIError(); + break; + } + } + controller.signal.throwIfAborted(); + await starts; + return; + } + const uiPort = ( await findFreePort(UI_DEFAULT_PORT, flags["ui-port"], config.checkPort, controller.signal) ).port;