From 5c359b92aa683ada778d547d683eb6fa1256ff61 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 6 Oct 2026 17:25:46 +0000 Subject: [PATCH 1/4] refactor(dev): use shared withUserCancellation util --- src/handlers/project/dev/index.ts | 358 +++++++++++++++--------------- src/runnable/index.tsx | 23 +- 2 files changed, 191 insertions(+), 190 deletions(-) diff --git a/src/handlers/project/dev/index.ts b/src/handlers/project/dev/index.ts index de3603cdd1..fe8a70509d 100644 --- a/src/handlers/project/dev/index.ts +++ b/src/handlers/project/dev/index.ts @@ -14,9 +14,9 @@ import { NotImplementedError, ResourceNotFoundError, SilentCLIError, - UserCancellationError, } from "../../../errors"; import type { AppIO, BrowserOpener, FileWatcher, PortChecker, startHttpServer } from "../../../io"; +import { withUserCancellation } from "../../../runnable"; import { createHandler, flag, ProjectKey, type Middleware } from "../../../router"; import { JsonRendererKey, type JsonRenderer } from "../../../tui"; import { JsonKey, RegionKey } from "../../keys"; @@ -133,197 +133,190 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => ), ], handle: async (ctx, flags) => { - const controller = new AbortController(); const json = ctx.require(JsonKey) ? ctx.require(JsonRendererKey) : undefined; - const interrupt = () => { - if (controller.signal.aborted) return; - config.io.stderr.write("Shutting down…\n"); - controller.abort(new UserCancellationError()); - }; - - const signals = ["SIGINT", "SIGTERM"] as const; - for (const signal of signals) process.on(signal, interrupt); - let collector: DevTraceCollector | undefined; - try { - const project = ctx.require(ProjectKey); - const region = ctx.require(RegionKey); - const runtimes = selectRuntimes(project, flags.agent); - if (runtimes.length > 1 && flags.port !== undefined) { - throw new InputValidationError( - "--port applies to a single runtime. Use --agent to select one.", - ); - } - if (!flags.agent) { - for (const runtime of project.spec.runtimes.filter( - (runtime) => !supportsLocalDev(runtime), - )) { - renderStatus( - config.io, - `Skipping runtime '${runtime.name}': local dev is not supported for ${BMA_TEMPLATE_NAME}.`, - json, - ); - } - } + const project = ctx.require(ProjectKey); + const region = ctx.require(RegionKey); + await withUserCancellation( + async (signal) => { + let collector: DevTraceCollector | undefined; + try { + const runtimes = selectRuntimes(project, flags.agent); + if (runtimes.length > 1 && flags.port !== undefined) { + throw new InputValidationError( + "--port applies to a single runtime. Use --agent to select one.", + ); + } + if (!flags.agent) { + for (const runtime of project.spec.runtimes.filter( + (runtime) => !supportsLocalDev(runtime), + )) { + renderStatus( + config.io, + `Skipping runtime '${runtime.name}': local dev is not supported for ${BMA_TEMPLATE_NAME}.`, + json, + ); + } + } - if ( - flags.traces && - runtimes.some((runtime) => runtime.instrumentation?.enableOtel ?? true) - ) { - const tracesDirectory = join(project.rootPath, "agentcore", ".cli", "traces", "otlp"); - let tracePersistErrorReported = false; - collector = await config.startTraceCollector({ - tracesDirectory, - // A container reaches the collector over the host bridge, which a - // 127.0.0.1 bind refuses, so bind all interfaces when any runtime - // is a container. - host: runtimes.some((runtime) => runtime.build === "Container") - ? "0.0.0.0" - : "127.0.0.1", - // Persistence can fail after startup (disk, permissions). Warn once — - // exports are still acked, so without this the loss would be silent. - onError: (error) => { - if (tracePersistErrorReported) return; - tracePersistErrorReported = true; - const detail = error instanceof Error ? error.message : String(error); + if ( + flags.traces && + runtimes.some((runtime) => runtime.instrumentation?.enableOtel ?? true) + ) { + const tracesDirectory = join(project.rootPath, "agentcore", ".cli", "traces", "otlp"); + let tracePersistErrorReported = false; + collector = await config.startTraceCollector({ + tracesDirectory, + // A container reaches the collector over the host bridge, which a + // 127.0.0.1 bind refuses, so bind all interfaces when any runtime + // is a container. + host: runtimes.some((runtime) => runtime.build === "Container") + ? "0.0.0.0" + : "127.0.0.1", + // Persistence can fail after startup (disk, permissions). Warn once — + // exports are still acked, so without this the loss would be silent. + onError: (error) => { + if (tracePersistErrorReported) return; + tracePersistErrorReported = true; + const detail = error instanceof Error ? error.message : String(error); + renderStatus( + config.io, + `Warning: failed to persist traces to ${tracesDirectory} (${detail}); collected traces may be incomplete.`, + json, + ); + }, + }); renderStatus( config.io, - `Warning: failed to persist traces to ${tracesDirectory} (${detail}); collected traces may be incomplete.`, + `OTEL collector listening on port ${collector.port}; traces persist to ${tracesDirectory}.`, json, ); - }, - }); - renderStatus( - config.io, - `OTEL collector listening on port ${collector.port}; traces persist to ${tracesDirectory}.`, - json, - ); - } - controller.signal.throwIfAborted(); - - const getDevEnvVarsForRuntime = async ( - runtime: ProjectRuntime, - ): Promise> => { - const { env } = await config.loadDevEnvironment({ - projectRoot: project.rootPath, - runtime, - region, - }); - const otel = - collector && (runtime.instrumentation?.enableOtel ?? true) - ? otelEnvForRuntime(collector, runtime) - : {}; - return { ...env, ...otel }; - }; + } + signal.throwIfAborted(); - if (flags.mode === "headless" && flags.agent) { - await runWithoutUi( - config, - runtimes[0]!, - project, - flags.port, - getDevEnvVarsForRuntime, - controller, - json, - ); - return; - } + const getDevEnvVarsForRuntime = async ( + runtime: ProjectRuntime, + ): Promise> => { + const { env } = await config.loadDevEnvironment({ + projectRoot: project.rootPath, + runtime, + region, + }); + const otel = + collector && (runtime.instrumentation?.enableOtel ?? true) + ? otelEnvForRuntime(collector, runtime) + : {}; + return { ...env, ...otel }; + }; - const assignedPorts = - flags.mode === "headless" - ? await resolveDevPorts(runtimes, flags.port, config.checkPort, controller.signal) - : undefined; - const supervisor = new DevSupervisor({ - runtimes, - projectRoot: project.rootPath, - runners: config.runners, - getDevEnvVarsForRuntime, - // The --port guard above rejects an explicit port with more than one - // runtime, so passing flags.port here only ever applies to a lone one. - resolvePort: async (runtime) => { - if (assignedPorts) { - const assignedPort = assignedPorts.get(runtime.name); - if (assignedPort === undefined) { - throw new AgentCoreCLIError(`No port was assigned to runtime '${runtime.name}'.`); - } - return assignedPort; - } - return ( - await resolveDevPort( - runtime.protocol, + if (flags.mode === "headless" && flags.agent) { + await runWithoutUi( + config, + runtimes[0]!, + project, flags.port, - config.checkPort, - controller.signal, - ) - ).port; - }, - waitReady: config.waitReady, - signal: controller.signal, - }); + getDevEnvVarsForRuntime, + signal, + json, + ); + return; + } + + const assignedPorts = + flags.mode === "headless" + ? await resolveDevPorts(runtimes, flags.port, config.checkPort, signal) + : undefined; + const supervisor = new DevSupervisor({ + runtimes, + projectRoot: project.rootPath, + runners: config.runners, + getDevEnvVarsForRuntime, + // The --port guard above rejects an explicit port with more than one + // runtime, so passing flags.port here only ever applies to a lone one. + resolvePort: async (runtime) => { + if (assignedPorts) { + const assignedPort = assignedPorts.get(runtime.name); + if (assignedPort === undefined) { + throw new AgentCoreCLIError( + `No port was assigned to runtime '${runtime.name}'.`, + ); + } + return assignedPort; + } + return ( + await resolveDevPort(runtime.protocol, flags.port, config.checkPort, signal) + ).port; + }, + waitReady: config.waitReady, + signal, + }); - if (flags.mode === "headless") { - void 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; + if (flags.mode === "headless") { + void 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; + } + } + signal.throwIfAborted(); + return; } - } - controller.signal.throwIfAborted(); - return; - } - const uiPort = ( - await findFreePort(UI_DEFAULT_PORT, flags["ui-port"], config.checkPort, controller.signal) - ).port; - const server = await config.startServer( - createInspectorHandler({ - supervisor, - traces: collector?.traces, - assets: config.inspectorAssets, - project, - selectedAgent: flags.agent, - }), - { port: uiPort, signal: controller.signal }, - ); + const uiPort = ( + await findFreePort(UI_DEFAULT_PORT, flags["ui-port"], config.checkPort, signal) + ).port; + const server = await config.startServer( + createInspectorHandler({ + supervisor, + traces: collector?.traces, + assets: config.inspectorAssets, + project, + selectedAgent: flags.agent, + }), + { port: uiPort, signal }, + ); - const onConfigChange = async () => { - try { - const reloaded = await config.projectManager.resolve({ filePath: project.rootPath }); - if (!reloaded) return; - const runtimes = reloaded.spec.runtimes.filter(supportsLocalDev); - supervisor.setRuntimes( - flags.agent ? runtimes.filter((runtime) => runtime.name === flags.agent) : runtimes, + const onConfigChange = async () => { + try { + const reloaded = await config.projectManager.resolve({ + filePath: project.rootPath, + }); + if (!reloaded) return; + const runtimes = reloaded.spec.runtimes.filter(supportsLocalDev); + supervisor.setRuntimes( + flags.agent + ? runtimes.filter((runtime) => runtime.name === flags.agent) + : runtimes, + ); + renderStatus(config.io, "Reloaded agents from agentcore.json.", json); + } catch { + // A half-saved config parses on the next change event. + } + }; + config.watchFile( + projectSpecPath(project.rootPath), + () => void onConfigChange(), + signal, ); - renderStatus(config.io, "Reloaded agents from agentcore.json.", json); - } catch { - // A half-saved config parses on the next change event. - } - }; - config.watchFile( - projectSpecPath(project.rootPath), - () => void onConfigChange(), - controller.signal, - ); - const url = `http://127.0.0.1:${server.port}`; - renderStatus(config.io, `Agent Inspector running at ${url}`, json); - if (config.isInteractive() && !json) await config.openBrowser(url); + const url = `http://127.0.0.1:${server.port}`; + renderStatus(config.io, `Agent Inspector running at ${url}`, json); + if (config.isInteractive() && !json) await config.openBrowser(url); - for await (const { agentName, event } of supervisor.events()) { - renderAgentEvent(config.io, event, agentName, json); - } - controller.signal.throwIfAborted(); - } catch (error) { - controller.signal.throwIfAborted(); - throw error; - } finally { - for (const signal of signals) process.removeListener(signal, interrupt); - // Close only after the runner returns, which is after the child's own - // shutdown grace, so the agent's final spans still reach the collector. - await collector?.close(); - } + for await (const { agentName, event } of supervisor.events()) { + renderAgentEvent(config.io, event, agentName, json); + } + signal.throwIfAborted(); + } finally { + // Close only after the runner returns, which is after the child's own + // shutdown grace, so the agent's final spans still reach the collector. + await collector?.close(); + } + }, + { onCancel: () => config.io.stderr.write("Shutting down…\n") }, + ); }, }); @@ -337,15 +330,10 @@ async function runWithoutUi( project: Project, explicitPort: number | undefined, environment: (runtime: ProjectRuntime) => Promise>, - controller: AbortController, + signal: AbortSignal, json?: JsonRenderer, ): Promise { - const devPort = await resolveDevPort( - runtime.protocol, - explicitPort, - config.checkPort, - controller.signal, - ); + const devPort = await resolveDevPort(runtime.protocol, explicitPort, config.checkPort, signal); if (devPort.port !== devPort.requestedPort) { renderStatus( config.io, @@ -355,7 +343,7 @@ async function runWithoutUi( } const env = await environment(runtime); - controller.signal.throwIfAborted(); + signal.throwIfAborted(); const runner = config.runners[runtime.build]; for await (const event of runner.run({ @@ -363,7 +351,7 @@ async function runWithoutUi( projectRoot: project.rootPath, port: devPort.port, env, - signal: controller.signal, + signal, })) { renderAgentEvent(config.io, event, runtime.name, json); } diff --git a/src/runnable/index.tsx b/src/runnable/index.tsx index 1cd2401671..0e0d92477a 100644 --- a/src/runnable/index.tsx +++ b/src/runnable/index.tsx @@ -1,11 +1,24 @@ import { AgentCoreCLIError, ExitCode, SilentCLIError, UserCancellationError } from "../errors"; -/** Runs a headless operation with process SIGINT mapped to UserCancellationError. */ -export async function withUserCancellation(fn: (signal: AbortSignal) => Promise): Promise { +/** Runs a headless operation with process interrupts mapped to UserCancellationError. */ +export async function withUserCancellation( + fn: (signal: AbortSignal) => Promise, + options: { onCancel?: () => void } = {}, +): Promise { const controller = new AbortController(); - const interrupt = () => controller.abort(new UserCancellationError()); + const signals = ["SIGINT", "SIGTERM"] as const; + let interrupted = false; + const interrupt = () => { + if (interrupted) return; + interrupted = true; + try { + options.onCancel?.(); + } finally { + controller.abort(new UserCancellationError()); + } + }; // Ink's signal-exit handler must see our listener until cancellation cleanup settles. - process.on("SIGINT", interrupt); + for (const signal of signals) process.on(signal, interrupt); try { const result = await fn(controller.signal); controller.signal.throwIfAborted(); @@ -15,7 +28,7 @@ export async function withUserCancellation(fn: (signal: AbortSignal) => Promi throw error; } finally { controller.abort(); - process.off("SIGINT", interrupt); + for (const signal of signals) process.off(signal, interrupt); } } From 98c5029181a7a3cfe26a98e3bed5b26c603aff74 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 6 Oct 2026 18:14:13 +0000 Subject: [PATCH 2/4] refactor(dev): use error type for agent failures to unify on DevSupervisor --- src/core/dev/inspector/server.test.ts | 2 +- src/core/dev/inspector/server.ts | 6 +- src/core/dev/inspector/types.ts | 2 +- src/core/dev/supervisor.test.ts | 6 +- src/core/dev/supervisor.ts | 48 +++-- src/handlers/project/dev/index.test.ts | 15 +- src/handlers/project/dev/index.ts | 246 +++++++++++++------------ 7 files changed, 180 insertions(+), 145 deletions(-) diff --git a/src/core/dev/inspector/server.test.ts b/src/core/dev/inspector/server.test.ts index b1f7335097..c6e97560e1 100644 --- a/src/core/dev/inspector/server.test.ts +++ b/src/core/dev/inspector/server.test.ts @@ -92,7 +92,7 @@ describe("GET /api/status", () => { buildType: "Container", protocol: "MCP", phase: "failed", - error: "boom", + error: new Error("boom"), }, ], }); diff --git a/src/core/dev/inspector/server.ts b/src/core/dev/inspector/server.ts index 28470b6d8e..ca4847c0f4 100644 --- a/src/core/dev/inspector/server.ts +++ b/src/core/dev/inspector/server.ts @@ -86,9 +86,9 @@ function handleStatus(deps: InspectorDeps): HttpResponse { running: snapshot .filter((agent) => agent.phase === "running" && agent.port !== undefined) .map(({ name, port }) => ({ name, port: port! })), - errors: snapshot - .filter((agent) => agent.error !== undefined) - .map(({ name, error }) => ({ name, message: error! })), + errors: snapshot.flatMap(({ name, error }) => + error ? [{ name, message: error.message }] : [], + ), selectedAgent: deps.selectedAgent, }; return json(200, status); diff --git a/src/core/dev/inspector/types.ts b/src/core/dev/inspector/types.ts index 3d8be57be5..e43f1ea0b5 100644 --- a/src/core/dev/inspector/types.ts +++ b/src/core/dev/inspector/types.ts @@ -14,7 +14,7 @@ export interface InspectorAgentStatus { protocol: string; phase: "idle" | "starting" | "running" | "failed"; port?: number; - error?: string; + error?: Error; } /** The slice of the dev supervisor the Inspector routes need. */ diff --git a/src/core/dev/supervisor.test.ts b/src/core/dev/supervisor.test.ts index 4c81d83b1c..dd8f7dd54b 100644 --- a/src/core/dev/supervisor.test.ts +++ b/src/core/dev/supervisor.test.ts @@ -179,8 +179,9 @@ describe("DevSupervisor", () => { expect(supervisor.snapshot()[0]).toMatchObject({ name: "orders", phase: "failed", - error: "boom", }); + expect(supervisor.snapshot()[0]?.error).toBeInstanceOf(Error); + expect(supervisor.snapshot()[0]?.error?.message).toBe("boom"); // Retry hits the runner again rather than being stuck. await supervisor.start("orders").catch(() => {}); @@ -262,8 +263,9 @@ describe("DevSupervisor", () => { expect(supervisor.snapshot()[0]).toMatchObject({ name: "orders", phase: "failed", - error: "segfault", }); + expect(supervisor.snapshot()[0]?.error).toBeInstanceOf(Error); + expect(supervisor.snapshot()[0]?.error?.message).toBe("segfault"); expect(supervisor.running("orders")).toBeUndefined(); const events = await drain(supervisor, controller); expect(events).toContainEqual({ diff --git a/src/core/dev/supervisor.ts b/src/core/dev/supervisor.ts index 289846468c..448b7c9f52 100644 --- a/src/core/dev/supervisor.ts +++ b/src/core/dev/supervisor.ts @@ -11,7 +11,7 @@ export interface AgentStatus { protocol: NonNullable; phase: AgentPhase; port?: number; - error?: string; + error?: Error; } /** A dev event tagged with the name of the runtime that produced it. */ @@ -41,7 +41,8 @@ type AgentEntry = { runtime: ProjectRuntime; phase: AgentPhase; port?: number; - error?: string; + error?: Error; + completed?: boolean; starting?: Promise<{ name: string; port: number }>; /** The running child's pump, so shutdown can await its final spans. */ running?: Promise; @@ -50,11 +51,10 @@ type AgentEntry = { }; /** - * Owns the lifecycle of every dev-able runtime for the Inspector: agents start - * lazily (triggered from the browser), each in its own abort scope chained off - * the command's signal, and every runner's events merge into one attributed - * stream the dev handler renders. Restart-on-edit stays inside the child - * (uvicorn --reload / tsx watch) — the supervisor never restarts processes. + * Owns the lifecycle of dev runtimes: agents can start lazily from the Inspector + * or eagerly in headless mode. Each runs in its own abort scope chained off the + * command's signal, and runner events merge into one attributed stream. Restart- + * on-edit stays inside the child (uvicorn --reload / tsx watch). */ export class DevSupervisor { private readonly agents = new Map(); @@ -183,7 +183,8 @@ export class DevSupervisor { private async launch(entry: AgentEntry): Promise<{ name: string; port: number }> { const name = entry.runtime.name; entry.phase = "starting"; - entry.error = undefined; + delete entry.error; + entry.completed = false; const controller = new AbortController(); const onParentAbort = () => controller.abort(this.config.signal.reason); @@ -207,8 +208,10 @@ export class DevSupervisor { }); entry.running = pump; const earlyExit = pump.finally(unchain).then(() => { - if (!ready) - throw new Error(entry.error ?? `Agent '${name}' exited before it became ready.`); + if (!ready) { + if (entry.error) throw entry.error; + throw new Error(`Agent '${name}' exited before it became ready.`); + } }); // Both branches outlive the race (the pump runs for the agent's lifetime); // swallow their late rejections so losing branches never become unhandled. @@ -216,6 +219,13 @@ export class DevSupervisor { earlyExit.catch(() => {}); await Promise.race([readiness, earlyExit]); + if (entry.completed) { + entry.phase = "idle"; + entry.port = undefined; + this.push(name, { type: "status", message: `Agent '${name}' stopped.` }); + return { name, port }; + } + entry.phase = "running"; entry.port = port; this.push(name, { type: "status", message: `Agent '${name}' is running on port ${port}.` }); @@ -224,10 +234,12 @@ export class DevSupervisor { controller.abort(); unchain(); // idempotent alongside the pump's cleanup; covers setup failures before the pump exists entry.phase = "failed"; - entry.error = error instanceof Error ? error.message : String(error); + const agentError = + error instanceof Error ? error : new Error(String(error), { cause: error }); + entry.error = agentError; this.push(name, { type: "status", - message: `Agent '${name}' failed to start: ${entry.error}`, + message: `Agent '${name}' failed to start: ${agentError.message}`, }); throw error; } @@ -256,6 +268,8 @@ export class DevSupervisor { entry.phase = "idle"; entry.port = undefined; this.push(name, { type: "status", message: `Agent '${name}' stopped.` }); + } else if (entry.phase === "starting") { + entry.completed = true; } } catch (error) { /** The runner rejects with the abort reason on teardown, which is a stop, not a crash. */ @@ -267,12 +281,16 @@ export class DevSupervisor { } return; } - const message = error instanceof Error ? error.message : String(error); - entry.error = message; + const agentError = + error instanceof Error ? error : new Error(String(error), { cause: error }); + entry.error = agentError; if (entry.phase === "running") { entry.phase = "failed"; entry.port = undefined; - this.push(name, { type: "status", message: `Agent '${name}' crashed: ${message}` }); + this.push(name, { + type: "status", + message: `Agent '${name}' crashed: ${agentError.message}`, + }); } } } diff --git a/src/handlers/project/dev/index.test.ts b/src/handlers/project/dev/index.test.ts index 8af08b8c63..2179f20884 100644 --- a/src/handlers/project/dev/index.test.ts +++ b/src/handlers/project/dev/index.test.ts @@ -109,6 +109,7 @@ type HarnessOptions = { codeZip?: ReturnType; container?: ReturnType; checkPort?: PortChecker; + waitReady?: NonNullable; json?: boolean; loadEnvironment?: DevProjectHandlerConfig["loadDevEnvironment"]; }; @@ -155,9 +156,7 @@ function harness(options: HarnessOptions = {}) { resolve: async () => options.reloadedRuntimes ? project(...options.reloadedRuntimes) : undefined, }, - waitReady: async () => { - await Bun.sleep(5); - }, + waitReady: options.waitReady ?? (async () => {}), }); const ctx = ValueContext.EmptyContext() .withValue(ProjectKey, options.project ?? project(runtime())) @@ -358,6 +357,9 @@ describe("project dev headless multi-agent", () => { project: twoRuntimes(), codeZip: captureRunner([{ type: "status", message: "dying" }]), container: stayingRunner(), + waitReady: async () => { + await Bun.sleep(5); + }, }); const { pending } = await supervised(subject); @@ -369,7 +371,12 @@ describe("project dev headless multi-agent", () => { }); test("exits non-zero when every agent fails to start", async () => { - const subject = harness({ project: twoRuntimes() }); + const subject = harness({ + project: twoRuntimes(), + waitReady: async () => { + await Bun.sleep(5); + }, + }); await expect(subject.run()).rejects.toBeInstanceOf(SilentCLIError); expect(subject.collector.state.closed).toBe(1); diff --git a/src/handlers/project/dev/index.ts b/src/handlers/project/dev/index.ts index fe8a70509d..84307ba552 100644 --- a/src/handlers/project/dev/index.ts +++ b/src/handlers/project/dev/index.ts @@ -61,7 +61,20 @@ function supportsLocalDev(runtime: ProjectRuntime): boolean { return !isBmaRuntime(runtime); } -function selectRuntimes(project: Project, name?: string): ProjectRuntime[] { +function selectRuntimes( + project: Project, + { + name, + port, + io, + json, + }: { + name: string | undefined; + port: number | undefined; + io: AppIO; + json: JsonRenderer | undefined; + }, +): ProjectRuntime[] { if (project.spec.runtimes.length === 0) { throw new InputValidationError( "This project has no runtimes. Add a runtime to agentcore/agentcore.json and retry.", @@ -84,6 +97,20 @@ function selectRuntimes(project: Project, name?: string): ProjectRuntime[] { { source: ERROR_SOURCE.USER }, ); } + if (supportedRuntimes.length > 1 && port !== undefined) { + throw new InputValidationError( + "--port applies to a single runtime. Use --agent to select one.", + ); + } + if (!name) { + for (const runtime of selectedRuntimes.filter((runtime) => !supportsLocalDev(runtime))) { + renderStatus( + io, + `Skipping runtime '${runtime.name}': local dev is not supported for ${BMA_TEMPLATE_NAME}.`, + json, + ); + } + } return supportedRuntimes; } @@ -108,6 +135,57 @@ function renderStatus(io: AppIO, message: string, json?: JsonRenderer): void { io.stderr.write(`${message}\n`); } +async function startTraceCollector( + config: DevProjectHandlerConfig, + project: Project, + runtimes: ProjectRuntime[], + json?: JsonRenderer, +): Promise { + const tracesDirectory = join(project.rootPath, "agentcore", ".cli", "traces", "otlp"); + let tracePersistErrorReported = false; + const collector = await config.startTraceCollector({ + tracesDirectory, + // A container reaches the collector over the host bridge, which a + // 127.0.0.1 bind refuses, so bind all interfaces when any runtime is + // a container. + host: runtimes.some((runtime) => runtime.build === "Container") ? "0.0.0.0" : "127.0.0.1", + // Persistence can fail after startup (disk, permissions). Warn once — + // exports are still acked, so without this the loss would be silent. + onError: (error) => { + if (tracePersistErrorReported) return; + tracePersistErrorReported = true; + const detail = error instanceof Error ? error.message : String(error); + renderStatus( + config.io, + `Warning: failed to persist traces to ${tracesDirectory} (${detail}); collected traces may be incomplete.`, + json, + ); + }, + }); + renderStatus( + config.io, + `OTEL collector listening on port ${collector.port}; traces persist to ${tracesDirectory}.`, + json, + ); + return collector; +} + +function createDevEnvironmentResolver( + loadDevEnvironment: DevEnvironmentLoader, + projectRoot: string, + region: string | undefined, + collector: DevTraceCollector | undefined, +): (runtime: ProjectRuntime) => Promise> { + return async (runtime) => { + const { env } = await loadDevEnvironment({ projectRoot, runtime, region }); + const otel = + collector && (runtime.instrumentation?.enableOtel ?? true) + ? otelEnvForRuntime(collector, runtime) + : {}; + return { ...env, ...otel }; + }; +} + export const createDevProjectHandler = (config: DevProjectHandlerConfig) => createHandler({ name: "dev", @@ -140,89 +218,29 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => async (signal) => { let collector: DevTraceCollector | undefined; try { - const runtimes = selectRuntimes(project, flags.agent); - if (runtimes.length > 1 && flags.port !== undefined) { - throw new InputValidationError( - "--port applies to a single runtime. Use --agent to select one.", - ); - } - if (!flags.agent) { - for (const runtime of project.spec.runtimes.filter( - (runtime) => !supportsLocalDev(runtime), - )) { - renderStatus( - config.io, - `Skipping runtime '${runtime.name}': local dev is not supported for ${BMA_TEMPLATE_NAME}.`, - json, - ); - } - } - + const runtimes = selectRuntimes(project, { + name: flags.agent, + port: flags.port, + io: config.io, + json, + }); if ( flags.traces && runtimes.some((runtime) => runtime.instrumentation?.enableOtel ?? true) ) { - const tracesDirectory = join(project.rootPath, "agentcore", ".cli", "traces", "otlp"); - let tracePersistErrorReported = false; - collector = await config.startTraceCollector({ - tracesDirectory, - // A container reaches the collector over the host bridge, which a - // 127.0.0.1 bind refuses, so bind all interfaces when any runtime - // is a container. - host: runtimes.some((runtime) => runtime.build === "Container") - ? "0.0.0.0" - : "127.0.0.1", - // Persistence can fail after startup (disk, permissions). Warn once — - // exports are still acked, so without this the loss would be silent. - onError: (error) => { - if (tracePersistErrorReported) return; - tracePersistErrorReported = true; - const detail = error instanceof Error ? error.message : String(error); - renderStatus( - config.io, - `Warning: failed to persist traces to ${tracesDirectory} (${detail}); collected traces may be incomplete.`, - json, - ); - }, - }); - renderStatus( - config.io, - `OTEL collector listening on port ${collector.port}; traces persist to ${tracesDirectory}.`, - json, - ); + collector = await startTraceCollector(config, project, runtimes, json); } signal.throwIfAborted(); - const getDevEnvVarsForRuntime = async ( - runtime: ProjectRuntime, - ): Promise> => { - const { env } = await config.loadDevEnvironment({ - projectRoot: project.rootPath, - runtime, - region, - }); - const otel = - collector && (runtime.instrumentation?.enableOtel ?? true) - ? otelEnvForRuntime(collector, runtime) - : {}; - return { ...env, ...otel }; - }; - - if (flags.mode === "headless" && flags.agent) { - await runWithoutUi( - config, - runtimes[0]!, - project, - flags.port, - getDevEnvVarsForRuntime, - signal, - json, - ); - return; - } + const getDevEnvVarsForRuntime = createDevEnvironmentResolver( + config.loadDevEnvironment, + project.rootPath, + region, + collector, + ); const assignedPorts = - flags.mode === "headless" + flags.mode === "headless" && !flags.agent ? await resolveDevPorts(runtimes, flags.port, config.checkPort, signal) : undefined; const supervisor = new DevSupervisor({ @@ -230,8 +248,7 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => projectRoot: project.rootPath, runners: config.runners, getDevEnvVarsForRuntime, - // The --port guard above rejects an explicit port with more than one - // runtime, so passing flags.port here only ever applies to a lone one. + // Runtime selection rejects an explicit port for multiple runtimes. resolvePort: async (runtime) => { if (assignedPorts) { const assignedPort = assignedPorts.get(runtime.name); @@ -242,9 +259,24 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => } return assignedPort; } - return ( - await resolveDevPort(runtime.protocol, flags.port, config.checkPort, signal) - ).port; + const resolved = await resolveDevPort( + runtime.protocol, + flags.port, + config.checkPort, + signal, + ); + if ( + flags.mode === "headless" && + flags.agent && + resolved.port !== resolved.requestedPort + ) { + renderStatus( + config.io, + `Port ${resolved.requestedPort} is in use; using ${resolved.port}.`, + json, + ); + } + return resolved.port; }, waitReady: config.waitReady, signal, @@ -253,10 +285,23 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => if (flags.mode === "headless") { void Promise.allSettled(runtimes.map((runtime) => supervisor.start(runtime.name))); for await (const { agentName, event } of supervisor.events()) { - renderAgentEvent(config.io, event, agentName, json); + const message = event.type === "status" ? event.message : undefined; + const lifecycleStatus = + flags.agent !== undefined && + (message === `Agent '${agentName}' stopped.` || + message?.startsWith(`Agent '${agentName}' is running on port `) || + message?.startsWith(`Agent '${agentName}' failed to start: `) || + message?.startsWith(`Agent '${agentName}' crashed: `)); + if (!lifecycleStatus) 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(); + if (phases.some(({ phase }) => phase === "failed")) { + const error = flags.agent + ? supervisor.snapshot().find(({ name }) => name === flags.agent)?.error + : undefined; + if (error !== undefined) throw error; + throw new SilentCLIError(); + } break; } } @@ -319,40 +364,3 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => ); }, }); - -/** - * Run one runtime directly. Unlike the supervised Inspector path, a crash here - * fails the command (scripts and CI rely on the non-zero exit). - */ -async function runWithoutUi( - config: DevProjectHandlerConfig, - runtime: ProjectRuntime, - project: Project, - explicitPort: number | undefined, - environment: (runtime: ProjectRuntime) => Promise>, - signal: AbortSignal, - json?: JsonRenderer, -): Promise { - const devPort = await resolveDevPort(runtime.protocol, explicitPort, config.checkPort, signal); - if (devPort.port !== devPort.requestedPort) { - renderStatus( - config.io, - `Port ${devPort.requestedPort} is in use; using ${devPort.port}.`, - json, - ); - } - - const env = await environment(runtime); - signal.throwIfAborted(); - - const runner = config.runners[runtime.build]; - for await (const event of runner.run({ - runtime, - projectRoot: project.rootPath, - port: devPort.port, - env, - signal, - })) { - renderAgentEvent(config.io, event, runtime.name, json); - } -} From 8d7e6590d87967066d1a78dd18162dac27b5a0d2 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 7 Oct 2026 14:17:41 +0000 Subject: [PATCH 3/4] fix(dev): preserve lifecycle and output during supervised runs --- src/core/dev/supervisor.test.ts | 6 +- src/core/dev/supervisor.ts | 26 +++- src/handlers/project/dev/index.test.ts | 15 +-- src/handlers/project/dev/index.ts | 180 +++++++++---------------- 4 files changed, 91 insertions(+), 136 deletions(-) diff --git a/src/core/dev/supervisor.test.ts b/src/core/dev/supervisor.test.ts index dd8f7dd54b..53e4441c95 100644 --- a/src/core/dev/supervisor.test.ts +++ b/src/core/dev/supervisor.test.ts @@ -179,9 +179,8 @@ describe("DevSupervisor", () => { expect(supervisor.snapshot()[0]).toMatchObject({ name: "orders", phase: "failed", + error: { message: "boom" }, }); - expect(supervisor.snapshot()[0]?.error).toBeInstanceOf(Error); - expect(supervisor.snapshot()[0]?.error?.message).toBe("boom"); // Retry hits the runner again rather than being stuck. await supervisor.start("orders").catch(() => {}); @@ -263,9 +262,8 @@ describe("DevSupervisor", () => { expect(supervisor.snapshot()[0]).toMatchObject({ name: "orders", phase: "failed", + error: { message: "segfault" }, }); - expect(supervisor.snapshot()[0]?.error).toBeInstanceOf(Error); - expect(supervisor.snapshot()[0]?.error?.message).toBe("segfault"); expect(supervisor.running("orders")).toBeUndefined(); const events = await drain(supervisor, controller); expect(events).toContainEqual({ diff --git a/src/core/dev/supervisor.ts b/src/core/dev/supervisor.ts index 448b7c9f52..266a6d0165 100644 --- a/src/core/dev/supervisor.ts +++ b/src/core/dev/supervisor.ts @@ -152,18 +152,30 @@ export class DevSupervisor { /** * The merged event stream of every agent this supervisor has started. Ends * when the supervisor's signal aborts and all pending events are drained. + * Headless consumers can also finish after every agent stops. */ - public async *events(): AsyncGenerator { + public async *events( + options: { untilStopped?: boolean } = {}, + ): AsyncGenerator { while (true) { for (const event of this.queue.splice(0)) yield event; if (this.config.signal.aborted) { // Let every live child finish shutting down so its final spans reach the // collector before the caller closes it, then drain what they emitted. - const running = [...this.agents.values()].map((entry) => entry.running).filter(Boolean); - await Promise.allSettled(running); + const pending = [...this.agents.values()] + .flatMap((entry) => [entry.starting, entry.running]) + .filter(Boolean); + await Promise.allSettled(pending); for (const event of this.queue.splice(0)) yield event; return; } + if ( + options.untilStopped && + [...this.agents.values()].every(({ phase }) => phase !== "starting" && phase !== "running") + ) { + if (this.queue.length > 0) continue; + return; + } await new Promise((resolve) => { this.wake = resolve; // A push during the yields above ran while wake was undefined, so its @@ -190,12 +202,16 @@ export class DevSupervisor { const onParentAbort = () => controller.abort(this.config.signal.reason); // Chained for the agent's whole lifetime (not just startup): the command's // Ctrl-C must tear down every running child. The pump removes it on exit. - this.config.signal.addEventListener("abort", onParentAbort, { once: true }); + if (this.config.signal.aborted) onParentAbort(); + else this.config.signal.addEventListener("abort", onParentAbort, { once: true }); const unchain = () => this.config.signal.removeEventListener("abort", onParentAbort); try { + controller.signal.throwIfAborted(); const port = await this.config.resolvePort(entry.runtime); + controller.signal.throwIfAborted(); const env = await this.config.getDevEnvVarsForRuntime(entry.runtime); + controller.signal.throwIfAborted(); const runner = this.config.runners[entry.runtime.build]; let ready = false; @@ -218,6 +234,7 @@ export class DevSupervisor { readiness.catch(() => {}); earlyExit.catch(() => {}); await Promise.race([readiness, earlyExit]); + if (entry.error) throw entry.error; if (entry.completed) { entry.phase = "idle"; @@ -233,6 +250,7 @@ export class DevSupervisor { } catch (error) { controller.abort(); unchain(); // idempotent alongside the pump's cleanup; covers setup failures before the pump exists + await entry.running; entry.phase = "failed"; const agentError = error instanceof Error ? error : new Error(String(error), { cause: error }); diff --git a/src/handlers/project/dev/index.test.ts b/src/handlers/project/dev/index.test.ts index 2179f20884..8af08b8c63 100644 --- a/src/handlers/project/dev/index.test.ts +++ b/src/handlers/project/dev/index.test.ts @@ -109,7 +109,6 @@ type HarnessOptions = { codeZip?: ReturnType; container?: ReturnType; checkPort?: PortChecker; - waitReady?: NonNullable; json?: boolean; loadEnvironment?: DevProjectHandlerConfig["loadDevEnvironment"]; }; @@ -156,7 +155,9 @@ function harness(options: HarnessOptions = {}) { resolve: async () => options.reloadedRuntimes ? project(...options.reloadedRuntimes) : undefined, }, - waitReady: options.waitReady ?? (async () => {}), + waitReady: async () => { + await Bun.sleep(5); + }, }); const ctx = ValueContext.EmptyContext() .withValue(ProjectKey, options.project ?? project(runtime())) @@ -357,9 +358,6 @@ describe("project dev headless multi-agent", () => { project: twoRuntimes(), codeZip: captureRunner([{ type: "status", message: "dying" }]), container: stayingRunner(), - waitReady: async () => { - await Bun.sleep(5); - }, }); const { pending } = await supervised(subject); @@ -371,12 +369,7 @@ describe("project dev headless multi-agent", () => { }); test("exits non-zero when every agent fails to start", async () => { - const subject = harness({ - project: twoRuntimes(), - waitReady: async () => { - await Bun.sleep(5); - }, - }); + const subject = harness({ project: twoRuntimes() }); await expect(subject.run()).rejects.toBeInstanceOf(SilentCLIError); expect(subject.collector.state.closed).toBe(1); diff --git a/src/handlers/project/dev/index.ts b/src/handlers/project/dev/index.ts index 84307ba552..3eff589f88 100644 --- a/src/handlers/project/dev/index.ts +++ b/src/handlers/project/dev/index.ts @@ -8,7 +8,6 @@ import { projectSpecPath } from "../../../core/project/fsUtils"; import { DevSupervisor, type SupervisorConfig } from "../../../core/dev/supervisor"; import type { ProjectRuntime } from "../../../projectSchemas/runtime"; import { - AgentCoreCLIError, ERROR_SOURCE, InputValidationError, NotImplementedError, @@ -61,20 +60,7 @@ function supportsLocalDev(runtime: ProjectRuntime): boolean { return !isBmaRuntime(runtime); } -function selectRuntimes( - project: Project, - { - name, - port, - io, - json, - }: { - name: string | undefined; - port: number | undefined; - io: AppIO; - json: JsonRenderer | undefined; - }, -): ProjectRuntime[] { +function selectRuntimes(project: Project, name?: string): ProjectRuntime[] { if (project.spec.runtimes.length === 0) { throw new InputValidationError( "This project has no runtimes. Add a runtime to agentcore/agentcore.json and retry.", @@ -97,20 +83,6 @@ function selectRuntimes( { source: ERROR_SOURCE.USER }, ); } - if (supportedRuntimes.length > 1 && port !== undefined) { - throw new InputValidationError( - "--port applies to a single runtime. Use --agent to select one.", - ); - } - if (!name) { - for (const runtime of selectedRuntimes.filter((runtime) => !supportsLocalDev(runtime))) { - renderStatus( - io, - `Skipping runtime '${runtime.name}': local dev is not supported for ${BMA_TEMPLATE_NAME}.`, - json, - ); - } - } return supportedRuntimes; } @@ -135,57 +107,6 @@ function renderStatus(io: AppIO, message: string, json?: JsonRenderer): void { io.stderr.write(`${message}\n`); } -async function startTraceCollector( - config: DevProjectHandlerConfig, - project: Project, - runtimes: ProjectRuntime[], - json?: JsonRenderer, -): Promise { - const tracesDirectory = join(project.rootPath, "agentcore", ".cli", "traces", "otlp"); - let tracePersistErrorReported = false; - const collector = await config.startTraceCollector({ - tracesDirectory, - // A container reaches the collector over the host bridge, which a - // 127.0.0.1 bind refuses, so bind all interfaces when any runtime is - // a container. - host: runtimes.some((runtime) => runtime.build === "Container") ? "0.0.0.0" : "127.0.0.1", - // Persistence can fail after startup (disk, permissions). Warn once — - // exports are still acked, so without this the loss would be silent. - onError: (error) => { - if (tracePersistErrorReported) return; - tracePersistErrorReported = true; - const detail = error instanceof Error ? error.message : String(error); - renderStatus( - config.io, - `Warning: failed to persist traces to ${tracesDirectory} (${detail}); collected traces may be incomplete.`, - json, - ); - }, - }); - renderStatus( - config.io, - `OTEL collector listening on port ${collector.port}; traces persist to ${tracesDirectory}.`, - json, - ); - return collector; -} - -function createDevEnvironmentResolver( - loadDevEnvironment: DevEnvironmentLoader, - projectRoot: string, - region: string | undefined, - collector: DevTraceCollector | undefined, -): (runtime: ProjectRuntime) => Promise> { - return async (runtime) => { - const { env } = await loadDevEnvironment({ projectRoot, runtime, region }); - const otel = - collector && (runtime.instrumentation?.enableOtel ?? true) - ? otelEnvForRuntime(collector, runtime) - : {}; - return { ...env, ...otel }; - }; -} - export const createDevProjectHandler = (config: DevProjectHandlerConfig) => createHandler({ name: "dev", @@ -218,27 +139,58 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => async (signal) => { let collector: DevTraceCollector | undefined; try { - const runtimes = selectRuntimes(project, { - name: flags.agent, - port: flags.port, - io: config.io, - json, - }); + const runtimes = selectRuntimes(project, flags.agent); + if (runtimes.length > 1 && flags.port !== undefined) { + throw new InputValidationError( + "--port applies to a single runtime. Use --agent to select one.", + ); + } + if (!flags.agent) { + for (const runtime of project.spec.runtimes.filter( + (runtime) => !supportsLocalDev(runtime), + )) { + renderStatus( + config.io, + `Skipping runtime '${runtime.name}': local dev is not supported for ${BMA_TEMPLATE_NAME}.`, + json, + ); + } + } if ( flags.traces && runtimes.some((runtime) => runtime.instrumentation?.enableOtel ?? true) ) { - collector = await startTraceCollector(config, project, runtimes, json); + const tracesDirectory = join(project.rootPath, "agentcore", ".cli", "traces", "otlp"); + let tracePersistErrorReported = false; + collector = await config.startTraceCollector({ + tracesDirectory, + // A container reaches the collector over the host bridge, which a + // 127.0.0.1 bind refuses, so bind all interfaces when any runtime is + // a container. + host: runtimes.some((runtime) => runtime.build === "Container") + ? "0.0.0.0" + : "127.0.0.1", + // Persistence can fail after startup (disk, permissions). Warn once — + // exports are still acked, so without this the loss would be silent. + onError: (error) => { + if (tracePersistErrorReported) return; + tracePersistErrorReported = true; + const detail = error instanceof Error ? error.message : String(error); + renderStatus( + config.io, + `Warning: failed to persist traces to ${tracesDirectory} (${detail}); collected traces may be incomplete.`, + json, + ); + }, + }); + renderStatus( + config.io, + `OTEL collector listening on port ${collector.port}; traces persist to ${tracesDirectory}.`, + json, + ); } signal.throwIfAborted(); - const getDevEnvVarsForRuntime = createDevEnvironmentResolver( - config.loadDevEnvironment, - project.rootPath, - region, - collector, - ); - const assignedPorts = flags.mode === "headless" && !flags.agent ? await resolveDevPorts(runtimes, flags.port, config.checkPort, signal) @@ -247,18 +199,19 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => runtimes, projectRoot: project.rootPath, runners: config.runners, - getDevEnvVarsForRuntime, + getDevEnvVarsForRuntime: async (runtime) => { + const { env } = await config.loadDevEnvironment({ + projectRoot: project.rootPath, + runtime, + region, + }); + return collector && (runtime.instrumentation?.enableOtel ?? true) + ? { ...env, ...otelEnvForRuntime(collector, runtime) } + : { ...env }; + }, // Runtime selection rejects an explicit port for multiple runtimes. resolvePort: async (runtime) => { - if (assignedPorts) { - const assignedPort = assignedPorts.get(runtime.name); - if (assignedPort === undefined) { - throw new AgentCoreCLIError( - `No port was assigned to runtime '${runtime.name}'.`, - ); - } - return assignedPort; - } + if (assignedPorts) return assignedPorts.get(runtime.name)!; const resolved = await resolveDevPort( runtime.protocol, flags.port, @@ -278,13 +231,15 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => } return resolved.port; }, - waitReady: config.waitReady, + // Selected headless dev streams the runner without a readiness deadline. + waitReady: + flags.mode === "headless" && flags.agent ? async () => {} : config.waitReady, signal, }); if (flags.mode === "headless") { void Promise.allSettled(runtimes.map((runtime) => supervisor.start(runtime.name))); - for await (const { agentName, event } of supervisor.events()) { + for await (const { agentName, event } of supervisor.events({ untilStopped: true })) { const message = event.type === "status" ? event.message : undefined; const lifecycleStatus = flags.agent !== undefined && @@ -293,19 +248,10 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => message?.startsWith(`Agent '${agentName}' failed to start: `) || message?.startsWith(`Agent '${agentName}' crashed: `)); if (!lifecycleStatus) renderAgentEvent(config.io, event, agentName, json); - const phases = supervisor.snapshot(); - if (phases.every(({ phase }) => phase !== "starting" && phase !== "running")) { - if (phases.some(({ phase }) => phase === "failed")) { - const error = flags.agent - ? supervisor.snapshot().find(({ name }) => name === flags.agent)?.error - : undefined; - if (error !== undefined) throw error; - throw new SilentCLIError(); - } - break; - } } signal.throwIfAborted(); + const failure = supervisor.snapshot().find(({ phase }) => phase === "failed"); + if (failure) throw flags.agent ? failure.error : new SilentCLIError(); return; } From 298e139cce0262286c41a34ccdf6dbff66b55c78 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 7 Oct 2026 14:58:07 +0000 Subject: [PATCH 4/4] refactor(dev): limit supervisor changes to path unification --- src/core/dev/supervisor.ts | 17 +++++++---------- src/handlers/project/dev/index.ts | 4 ++-- src/runnable/index.tsx | 11 +++-------- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/src/core/dev/supervisor.ts b/src/core/dev/supervisor.ts index 266a6d0165..53350a5f1d 100644 --- a/src/core/dev/supervisor.ts +++ b/src/core/dev/supervisor.ts @@ -51,10 +51,11 @@ type AgentEntry = { }; /** - * Owns the lifecycle of dev runtimes: agents can start lazily from the Inspector - * or eagerly in headless mode. Each runs in its own abort scope chained off the - * command's signal, and runner events merge into one attributed stream. Restart- - * on-edit stays inside the child (uvicorn --reload / tsx watch). + * Owns the lifecycle of every dev-able runtime for the Inspector: agents start + * lazily (triggered from the browser), each in its own abort scope chained off + * the command's signal, and every runner's events merge into one attributed + * stream the dev handler renders. Restart-on-edit stays inside the child + * (uvicorn --reload / tsx watch) — the supervisor never restarts processes. */ export class DevSupervisor { private readonly agents = new Map(); @@ -195,21 +196,18 @@ export class DevSupervisor { private async launch(entry: AgentEntry): Promise<{ name: string; port: number }> { const name = entry.runtime.name; entry.phase = "starting"; - delete entry.error; + entry.error = undefined; entry.completed = false; const controller = new AbortController(); const onParentAbort = () => controller.abort(this.config.signal.reason); // Chained for the agent's whole lifetime (not just startup): the command's // Ctrl-C must tear down every running child. The pump removes it on exit. - if (this.config.signal.aborted) onParentAbort(); - else this.config.signal.addEventListener("abort", onParentAbort, { once: true }); + this.config.signal.addEventListener("abort", onParentAbort, { once: true }); const unchain = () => this.config.signal.removeEventListener("abort", onParentAbort); try { - controller.signal.throwIfAborted(); const port = await this.config.resolvePort(entry.runtime); - controller.signal.throwIfAborted(); const env = await this.config.getDevEnvVarsForRuntime(entry.runtime); controller.signal.throwIfAborted(); const runner = this.config.runners[entry.runtime.build]; @@ -250,7 +248,6 @@ export class DevSupervisor { } catch (error) { controller.abort(); unchain(); // idempotent alongside the pump's cleanup; covers setup failures before the pump exists - await entry.running; entry.phase = "failed"; const agentError = error instanceof Error ? error : new Error(String(error), { cause: error }); diff --git a/src/handlers/project/dev/index.ts b/src/handlers/project/dev/index.ts index 3eff589f88..3a12d98cb5 100644 --- a/src/handlers/project/dev/index.ts +++ b/src/handlers/project/dev/index.ts @@ -165,8 +165,8 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => collector = await config.startTraceCollector({ tracesDirectory, // A container reaches the collector over the host bridge, which a - // 127.0.0.1 bind refuses, so bind all interfaces when any runtime is - // a container. + // 127.0.0.1 bind refuses, so bind all interfaces when any runtime + // is a container. host: runtimes.some((runtime) => runtime.build === "Container") ? "0.0.0.0" : "127.0.0.1", diff --git a/src/runnable/index.tsx b/src/runnable/index.tsx index 0e0d92477a..a9a16262a3 100644 --- a/src/runnable/index.tsx +++ b/src/runnable/index.tsx @@ -7,15 +7,10 @@ export async function withUserCancellation( ): Promise { const controller = new AbortController(); const signals = ["SIGINT", "SIGTERM"] as const; - let interrupted = false; const interrupt = () => { - if (interrupted) return; - interrupted = true; - try { - options.onCancel?.(); - } finally { - controller.abort(new UserCancellationError()); - } + if (controller.signal.aborted) return; + options.onCancel?.(); + controller.abort(new UserCancellationError()); }; // Ink's signal-exit handler must see our listener until cancellation cleanup settles. for (const signal of signals) process.on(signal, interrupt);