From 45a092e3aeb438ef11eea8580491c2a72322f5d0 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 27 Aug 2026 20:49:41 -0400 Subject: [PATCH] feat(dev): supervise every runtime in headless mode --mode headless without --agent now runs all of the project's runtimes at once with attributed output, matching the supervised behavior browser mode gets from the Inspector. One agent crashing leaves the others running, and the command exits non-zero only when nothing is left running and something failed. --agent keeps the direct single-runtime path where a crash fails the command. --- src/handlers/project/dev/index.test.ts | 97 +++++++++++++++++++++----- src/handlers/project/dev/index.ts | 28 +++++--- 2 files changed, 100 insertions(+), 25 deletions(-) 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;