Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 80 additions & 17 deletions src/handlers/project/dev/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import type { ProjectRuntime } from "../../../projectSchemas/runtime";
import {
InputValidationError,
ResourceNotFoundError,
SilentCLIError,
UserCancellationError,
} from "../../../errors";
import type { HttpRequestHandler, PortChecker } from "../../../io";
Expand DownExpand Up@@ -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<void>((resolve) =>
input.signal.addEventListener("abort", () => resolve(), { once: true }),
);
},
};
return { runner, inputs };
}

function fakeCollector() {
const starts: Parameters<DevProjectHandlerConfig["startTraceCollector"]>[0][] = [];
const state = { closed: 0 };
Expand DownExpand Up@@ -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()))
Expand DownExpand Up@@ -167,16 +187,10 @@ async function inspectorStatus(subject: ReturnType<typeof harness>): 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 <name> to choose which one. Available: orders",
InputValidationError,
],
[
project(runtime("orders"), runtime("support", "Container")),
{},
"--mode headless runs a single agent in the terminal. Pass --agent <name> to choose which one. Available: orders, support",
{ port: 4567 },
"--port applies to a single runtime. Use --agent to select one.",
InputValidationError,
],
[
Expand DownExpand Up@@ -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<typeof harness>) {
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();
Expand DownExpand Up@@ -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 () => {
Expand Down
25 changes: 17 additions & 8 deletions src/handlers/project/dev/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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(
Expand DownExpand Up@@ -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 <name> 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.",
Expand DownExpand Up@@ -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]!,
Expand DownExpand Up@@ -227,6 +222,20 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) =>
signal: controller.signal,
});

if (flags.mode === "headless") {
void Promise.allSettled(runtimes.map((runtime) => supervisor.start(runtime.name)));
for await (const { agentName, event } of supervisor.events()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think normal Ctrl-C gets reported as a crash in this supervised headless path. The production process runner rejects with the child signal's UserCancellationError, so the supervisor emits Agent 'orders' crashed: Operation cancelled by user before the command exits 130. Could the supervisor treat an error from an already-aborted child signal as a normal stop? A test where the runner throws input.signal.reason on abort would cover the production behavior better than stayingRunner, which returns cleanly.

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

const uiPort = (
await findFreePort(UI_DEFAULT_PORT, flags["ui-port"], config.checkPort, controller.signal)
).port;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(dev): supervise every runtime in headless mode by tejaskash · Pull Request #2138 · aws/agentcore-cli · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 80 additions & 17 deletions src/handlers/project/dev/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import type { ProjectRuntime } from "../../../projectSchemas/runtime";
import {
InputValidationError,
ResourceNotFoundError,
SilentCLIError,
UserCancellationError,
} from "../../../errors";
import type { HttpRequestHandler, PortChecker } from "../../../io";
Expand DownExpand Up@@ -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<void>((resolve) =>
input.signal.addEventListener("abort", () => resolve(), { once: true }),
);
},
};
return { runner, inputs };
}

function fakeCollector() {
const starts: Parameters<DevProjectHandlerConfig["startTraceCollector"]>[0][] = [];
const state = { closed: 0 };
Expand DownExpand Up@@ -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()))
Expand DownExpand Up@@ -167,16 +187,10 @@ async function inspectorStatus(subject: ReturnType<typeof harness>): 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 <name> to choose which one. Available: orders",
InputValidationError,
],
[
project(runtime("orders"), runtime("support", "Container")),
{},
"--mode headless runs a single agent in the terminal. Pass --agent <name> to choose which one. Available: orders, support",
{ port: 4567 },
"--port applies to a single runtime. Use --agent to select one.",
InputValidationError,
],
[
Expand DownExpand Up@@ -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<typeof harness>) {
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();
Expand DownExpand Up@@ -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 () => {
Expand Down
25 changes: 17 additions & 8 deletions src/handlers/project/dev/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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(
Expand DownExpand Up@@ -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 <name> 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.",
Expand DownExpand Up@@ -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]!,
Expand DownExpand Up@@ -227,6 +222,20 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) =>
signal: controller.signal,
});

if (flags.mode === "headless") {
void Promise.allSettled(runtimes.map((runtime) => supervisor.start(runtime.name)));
for await (const { agentName, event } of supervisor.events()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think normal Ctrl-C gets reported as a crash in this supervised headless path. The production process runner rejects with the child signal's UserCancellationError, so the supervisor emits Agent 'orders' crashed: Operation cancelled by user before the command exits 130. Could the supervisor treat an error from an already-aborted child signal as a normal stop? A test where the runner throws input.signal.reason on abort would cover the production behavior better than stayingRunner, which returns cleanly.

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

const uiPort = (
await findFreePort(UI_DEFAULT_PORT, flags["ui-port"], config.checkPort, controller.signal)
).port;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(dev): supervise every runtime in headless mode by tejaskash · Pull Request #2138 · aws/agentcore-cli · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 80 additions & 17 deletions src/handlers/project/dev/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import type { ProjectRuntime } from "../../../projectSchemas/runtime";
import {
InputValidationError,
ResourceNotFoundError,
SilentCLIError,
UserCancellationError,
} from "../../../errors";
import type { HttpRequestHandler, PortChecker } from "../../../io";
Expand DownExpand Up@@ -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<void>((resolve) =>
input.signal.addEventListener("abort", () => resolve(), { once: true }),
);
},
};
return { runner, inputs };
}

function fakeCollector() {
const starts: Parameters<DevProjectHandlerConfig["startTraceCollector"]>[0][] = [];
const state = { closed: 0 };
Expand DownExpand Up@@ -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()))
Expand DownExpand Up@@ -167,16 +187,10 @@ async function inspectorStatus(subject: ReturnType<typeof harness>): 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 <name> to choose which one. Available: orders",
InputValidationError,
],
[
project(runtime("orders"), runtime("support", "Container")),
{},
"--mode headless runs a single agent in the terminal. Pass --agent <name> to choose which one. Available: orders, support",
{ port: 4567 },
"--port applies to a single runtime. Use --agent to select one.",
InputValidationError,
],
[
Expand DownExpand Up@@ -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<typeof harness>) {
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();
Expand DownExpand Up@@ -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 () => {
Expand Down
25 changes: 17 additions & 8 deletions src/handlers/project/dev/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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(
Expand DownExpand Up@@ -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 <name> 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.",
Expand DownExpand Up@@ -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]!,
Expand DownExpand Up@@ -227,6 +222,20 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) =>
signal: controller.signal,
});

if (flags.mode === "headless") {
void Promise.allSettled(runtimes.map((runtime) => supervisor.start(runtime.name)));
for await (const { agentName, event } of supervisor.events()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think normal Ctrl-C gets reported as a crash in this supervised headless path. The production process runner rejects with the child signal's UserCancellationError, so the supervisor emits Agent 'orders' crashed: Operation cancelled by user before the command exits 130. Could the supervisor treat an error from an already-aborted child signal as a normal stop? A test where the runner throws input.signal.reason on abort would cover the production behavior better than stayingRunner, which returns cleanly.

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

const uiPort = (
await findFreePort(UI_DEFAULT_PORT, flags["ui-port"], config.checkPort, controller.signal)
).port;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(dev): supervise every runtime in headless mode by tejaskash · Pull Request #2138 · aws/agentcore-cli · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 80 additions & 17 deletions src/handlers/project/dev/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import type { ProjectRuntime } from "../../../projectSchemas/runtime";
import {
InputValidationError,
ResourceNotFoundError,
SilentCLIError,
UserCancellationError,
} from "../../../errors";
import type { HttpRequestHandler, PortChecker } from "../../../io";
Expand DownExpand Up@@ -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<void>((resolve) =>
input.signal.addEventListener("abort", () => resolve(), { once: true }),
);
},
};
return { runner, inputs };
}

function fakeCollector() {
const starts: Parameters<DevProjectHandlerConfig["startTraceCollector"]>[0][] = [];
const state = { closed: 0 };
Expand DownExpand Up@@ -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()))
Expand DownExpand Up@@ -167,16 +187,10 @@ async function inspectorStatus(subject: ReturnType<typeof harness>): 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 <name> to choose which one. Available: orders",
InputValidationError,
],
[
project(runtime("orders"), runtime("support", "Container")),
{},
"--mode headless runs a single agent in the terminal. Pass --agent <name> to choose which one. Available: orders, support",
{ port: 4567 },
"--port applies to a single runtime. Use --agent to select one.",
InputValidationError,
],
[
Expand DownExpand Up@@ -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<typeof harness>) {
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();
Expand DownExpand Up@@ -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 () => {
Expand Down
25 changes: 17 additions & 8 deletions src/handlers/project/dev/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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(
Expand DownExpand Up@@ -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 <name> 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.",
Expand DownExpand Up@@ -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]!,
Expand DownExpand Up@@ -227,6 +222,20 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) =>
signal: controller.signal,
});

if (flags.mode === "headless") {
void Promise.allSettled(runtimes.map((runtime) => supervisor.start(runtime.name)));
for await (const { agentName, event } of supervisor.events()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think normal Ctrl-C gets reported as a crash in this supervised headless path. The production process runner rejects with the child signal's UserCancellationError, so the supervisor emits Agent 'orders' crashed: Operation cancelled by user before the command exits 130. Could the supervisor treat an error from an already-aborted child signal as a normal stop? A test where the runner throws input.signal.reason on abort would cover the production behavior better than stayingRunner, which returns cleanly.

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

const uiPort = (
await findFreePort(UI_DEFAULT_PORT, flags["ui-port"], config.checkPort, controller.signal)
).port;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(dev): supervise every runtime in headless mode by tejaskash · Pull Request #2138 · aws/agentcore-cli · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 80 additions & 17 deletions src/handlers/project/dev/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import type { ProjectRuntime } from "../../../projectSchemas/runtime";
import {
InputValidationError,
ResourceNotFoundError,
SilentCLIError,
UserCancellationError,
} from "../../../errors";
import type { HttpRequestHandler, PortChecker } from "../../../io";
Expand DownExpand Up@@ -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<void>((resolve) =>
input.signal.addEventListener("abort", () => resolve(), { once: true }),
);
},
};
return { runner, inputs };
}

function fakeCollector() {
const starts: Parameters<DevProjectHandlerConfig["startTraceCollector"]>[0][] = [];
const state = { closed: 0 };
Expand DownExpand Up@@ -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()))
Expand DownExpand Up@@ -167,16 +187,10 @@ async function inspectorStatus(subject: ReturnType<typeof harness>): 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 <name> to choose which one. Available: orders",
InputValidationError,
],
[
project(runtime("orders"), runtime("support", "Container")),
{},
"--mode headless runs a single agent in the terminal. Pass --agent <name> to choose which one. Available: orders, support",
{ port: 4567 },
"--port applies to a single runtime. Use --agent to select one.",
InputValidationError,
],
[
Expand DownExpand Up@@ -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<typeof harness>) {
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();
Expand DownExpand Up@@ -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 () => {
Expand Down
25 changes: 17 additions & 8 deletions src/handlers/project/dev/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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(
Expand DownExpand Up@@ -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 <name> 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.",
Expand DownExpand Up@@ -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]!,
Expand DownExpand Up@@ -227,6 +222,20 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) =>
signal: controller.signal,
});

if (flags.mode === "headless") {
void Promise.allSettled(runtimes.map((runtime) => supervisor.start(runtime.name)));
for await (const { agentName, event } of supervisor.events()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think normal Ctrl-C gets reported as a crash in this supervised headless path. The production process runner rejects with the child signal's UserCancellationError, so the supervisor emits Agent 'orders' crashed: Operation cancelled by user before the command exits 130. Could the supervisor treat an error from an already-aborted child signal as a normal stop? A test where the runner throws input.signal.reason on abort would cover the production behavior better than stayingRunner, which returns cleanly.

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

const uiPort = (
await findFreePort(UI_DEFAULT_PORT, flags["ui-port"], config.checkPort, controller.signal)
).port;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(dev): supervise every runtime in headless mode by tejaskash · Pull Request #2138 · aws/agentcore-cli · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 80 additions & 17 deletions src/handlers/project/dev/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import type { ProjectRuntime } from "../../../projectSchemas/runtime";
import {
InputValidationError,
ResourceNotFoundError,
SilentCLIError,
UserCancellationError,
} from "../../../errors";
import type { HttpRequestHandler, PortChecker } from "../../../io";
Expand DownExpand Up@@ -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<void>((resolve) =>
input.signal.addEventListener("abort", () => resolve(), { once: true }),
);
},
};
return { runner, inputs };
}

function fakeCollector() {
const starts: Parameters<DevProjectHandlerConfig["startTraceCollector"]>[0][] = [];
const state = { closed: 0 };
Expand DownExpand Up@@ -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()))
Expand DownExpand Up@@ -167,16 +187,10 @@ async function inspectorStatus(subject: ReturnType<typeof harness>): 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 <name> to choose which one. Available: orders",
InputValidationError,
],
[
project(runtime("orders"), runtime("support", "Container")),
{},
"--mode headless runs a single agent in the terminal. Pass --agent <name> to choose which one. Available: orders, support",
{ port: 4567 },
"--port applies to a single runtime. Use --agent to select one.",
InputValidationError,
],
[
Expand DownExpand Up@@ -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<typeof harness>) {
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();
Expand DownExpand Up@@ -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 () => {
Expand Down
25 changes: 17 additions & 8 deletions src/handlers/project/dev/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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(
Expand DownExpand Up@@ -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 <name> 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.",
Expand DownExpand Up@@ -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]!,
Expand DownExpand Up@@ -227,6 +222,20 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) =>
signal: controller.signal,
});

if (flags.mode === "headless") {
void Promise.allSettled(runtimes.map((runtime) => supervisor.start(runtime.name)));
for await (const { agentName, event } of supervisor.events()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think normal Ctrl-C gets reported as a crash in this supervised headless path. The production process runner rejects with the child signal's UserCancellationError, so the supervisor emits Agent 'orders' crashed: Operation cancelled by user before the command exits 130. Could the supervisor treat an error from an already-aborted child signal as a normal stop? A test where the runner throws input.signal.reason on abort would cover the production behavior better than stayingRunner, which returns cleanly.

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

const uiPort = (
await findFreePort(UI_DEFAULT_PORT, flags["ui-port"], config.checkPort, controller.signal)
).port;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(dev): supervise every runtime in headless mode by tejaskash · Pull Request #2138 · aws/agentcore-cli · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 80 additions & 17 deletions src/handlers/project/dev/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import type { ProjectRuntime } from "../../../projectSchemas/runtime";
import {
InputValidationError,
ResourceNotFoundError,
SilentCLIError,
UserCancellationError,
} from "../../../errors";
import type { HttpRequestHandler, PortChecker } from "../../../io";
Expand DownExpand Up@@ -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<void>((resolve) =>
input.signal.addEventListener("abort", () => resolve(), { once: true }),
);
},
};
return { runner, inputs };
}

function fakeCollector() {
const starts: Parameters<DevProjectHandlerConfig["startTraceCollector"]>[0][] = [];
const state = { closed: 0 };
Expand DownExpand Up@@ -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()))
Expand DownExpand Up@@ -167,16 +187,10 @@ async function inspectorStatus(subject: ReturnType<typeof harness>): 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 <name> to choose which one. Available: orders",
InputValidationError,
],
[
project(runtime("orders"), runtime("support", "Container")),
{},
"--mode headless runs a single agent in the terminal. Pass --agent <name> to choose which one. Available: orders, support",
{ port: 4567 },
"--port applies to a single runtime. Use --agent to select one.",
InputValidationError,
],
[
Expand DownExpand Up@@ -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<typeof harness>) {
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();
Expand DownExpand Up@@ -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 () => {
Expand Down
25 changes: 17 additions & 8 deletions src/handlers/project/dev/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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(
Expand DownExpand Up@@ -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 <name> 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.",
Expand DownExpand Up@@ -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]!,
Expand DownExpand Up@@ -227,6 +222,20 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) =>
signal: controller.signal,
});

if (flags.mode === "headless") {
void Promise.allSettled(runtimes.map((runtime) => supervisor.start(runtime.name)));
for await (const { agentName, event } of supervisor.events()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think normal Ctrl-C gets reported as a crash in this supervised headless path. The production process runner rejects with the child signal's UserCancellationError, so the supervisor emits Agent 'orders' crashed: Operation cancelled by user before the command exits 130. Could the supervisor treat an error from an already-aborted child signal as a normal stop? A test where the runner throws input.signal.reason on abort would cover the production behavior better than stayingRunner, which returns cleanly.

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

const uiPort = (
await findFreePort(UI_DEFAULT_PORT, flags["ui-port"], config.checkPort, controller.signal)
).port;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(dev): supervise every runtime in headless mode by tejaskash · Pull Request #2138 · aws/agentcore-cli · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 80 additions & 17 deletions src/handlers/project/dev/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import type { ProjectRuntime } from "../../../projectSchemas/runtime";
import {
InputValidationError,
ResourceNotFoundError,
SilentCLIError,
UserCancellationError,
} from "../../../errors";
import type { HttpRequestHandler, PortChecker } from "../../../io";
Expand DownExpand Up@@ -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<void>((resolve) =>
input.signal.addEventListener("abort", () => resolve(), { once: true }),
);
},
};
return { runner, inputs };
}

function fakeCollector() {
const starts: Parameters<DevProjectHandlerConfig["startTraceCollector"]>[0][] = [];
const state = { closed: 0 };
Expand DownExpand Up@@ -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()))
Expand DownExpand Up@@ -167,16 +187,10 @@ async function inspectorStatus(subject: ReturnType<typeof harness>): 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 <name> to choose which one. Available: orders",
InputValidationError,
],
[
project(runtime("orders"), runtime("support", "Container")),
{},
"--mode headless runs a single agent in the terminal. Pass --agent <name> to choose which one. Available: orders, support",
{ port: 4567 },
"--port applies to a single runtime. Use --agent to select one.",
InputValidationError,
],
[
Expand DownExpand Up@@ -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<typeof harness>) {
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();
Expand DownExpand Up@@ -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 () => {
Expand Down
25 changes: 17 additions & 8 deletions src/handlers/project/dev/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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(
Expand DownExpand Up@@ -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 <name> 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.",
Expand DownExpand Up@@ -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]!,
Expand DownExpand Up@@ -227,6 +222,20 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) =>
signal: controller.signal,
});

if (flags.mode === "headless") {
void Promise.allSettled(runtimes.map((runtime) => supervisor.start(runtime.name)));
for await (const { agentName, event } of supervisor.events()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think normal Ctrl-C gets reported as a crash in this supervised headless path. The production process runner rejects with the child signal's UserCancellationError, so the supervisor emits Agent 'orders' crashed: Operation cancelled by user before the command exits 130. Could the supervisor treat an error from an already-aborted child signal as a normal stop? A test where the runner throws input.signal.reason on abort would cover the production behavior better than stayingRunner, which returns cleanly.

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

const uiPort = (
await findFreePort(UI_DEFAULT_PORT, flags["ui-port"], config.checkPort, controller.signal)
).port;
Expand Down
Loading