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
94 changes: 94 additions & 0 deletions packages/cli/__tests__/commands/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ vi.mock("ora", () => ({
stop: vi.fn().mockReturnThis(),
succeed: vi.fn().mockReturnThis(),
fail: vi.fn().mockReturnThis(),
info: vi.fn().mockReturnThis(),
text: "",
}),
}));
Expand Down Expand Up @@ -196,6 +197,8 @@ beforeEach(() => {
const fakeChild = { on: vi.fn(), kill: vi.fn(), emit: vi.fn(), stdout: null, stderr: null };
mockSpawn.mockReturnValue(fakeChild);

mockSessionManager.list.mockReset();
mockSessionManager.list.mockResolvedValue([]);
mockSessionManager.get.mockReset();
mockSessionManager.spawnOrchestrator.mockReset();
mockSessionManager.kill.mockReset();
Expand Down Expand Up @@ -859,6 +862,97 @@ describe("start command — orchestrator session strategy display", () => {
expect(output).not.toContain("reused existing session");
},
);

it("handles existing orchestrator sessions by auto-selecting when --no-dashboard", async () => {
mockConfigRef.current = makeConfig({ "my-app": makeProject() });

// Return an existing orchestrator session
mockSessionManager.list.mockResolvedValue([
{
id: "app-orchestrator",
projectId: "my-app",
metadata: { role: "orchestrator" },
lastActivityAt: new Date(),
runtimeHandle: { id: "tmux-session-existing" },
},
]);

await program.parseAsync(["node", "test", "start", "--no-dashboard"]);

const output = getLoggedOutput();
// When --no-dashboard is used, auto-selects the most recent orchestrator
// and shows the tmux attach command (not the dashboard selection message)
expect(output).toContain("tmux attach -t tmux-session-existing");
expect(output).not.toContain("existing sessions found — select one in the dashboard");

// Should NOT spawn a new orchestrator when existing ones exist
expect(mockSessionManager.spawnOrchestrator).not.toHaveBeenCalled();
});

it("shows dashboard selection message when existing orchestrators found with dashboard enabled", async () => {
mockConfigRef.current = makeConfig({ "my-app": makeProject() });

// Mock findWebDir
const { findWebDir } = await import("../../src/lib/web-dir.js");
vi.mocked(findWebDir).mockReturnValue(tmpDir);
writeFileSync(join(tmpDir, "package.json"), "{}");

const fakeDashboard = {
on: vi.fn(),
kill: vi.fn(),
emit: vi.fn(),
};
mockSpawn.mockReturnValue(fakeDashboard);

// Return an existing orchestrator session
mockSessionManager.list.mockResolvedValue([
{
id: "app-orchestrator",
projectId: "my-app",
metadata: { role: "orchestrator" },
lastActivityAt: new Date(),
},
]);

await program.parseAsync(["node", "test", "start"]);

const output = getLoggedOutput();
// When dashboard is enabled, shows selection message
expect(output).toContain("existing sessions found — select one in the dashboard");

// Should NOT spawn a new orchestrator when existing ones exist
expect(mockSessionManager.spawnOrchestrator).not.toHaveBeenCalled();
});

it("fails and cleans up dashboard when orchestrator setup throws", async () => {
mockConfigRef.current = makeConfig({ "my-app": makeProject() });

// Mock findWebDir
const { findWebDir } = await import("../../src/lib/web-dir.js");
vi.mocked(findWebDir).mockReturnValue(tmpDir);
writeFileSync(join(tmpDir, "package.json"), "{}");

const fakeDashboard = {
on: vi.fn(),
kill: vi.fn(),
emit: vi.fn(),
};
mockSpawn.mockReturnValue(fakeDashboard);

mockSessionManager.list.mockResolvedValue([]);
mockSessionManager.spawnOrchestrator.mockRejectedValue(new Error("Spawn failed"));

await expect(program.parseAsync(["node", "test", "start"])).rejects.toThrow("process.exit(1)");

const errors = vi
.mocked(console.error)
.mock.calls.map((c) => c.join(" "))
.join("\n");
expect(errors).toContain("Failed to setup orchestrator: Spawn failed");

// Should have killed the dashboard
expect(fakeDashboard.kill).toHaveBeenCalled();
});
});

// ---------------------------------------------------------------------------
Expand Down
105 changes: 83 additions & 22 deletions packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
generateConfigFromUrl,
configToYaml,
normalizeOrchestratorSessionStrategy,
isOrchestratorSession,
ConfigNotFoundError,
type OrchestratorConfig,
type ProjectConfig,
Expand Down Expand Up @@ -987,32 +988,82 @@ async function runStartup(
}
}

// Create orchestrator session (unless --no-orchestrator or already exists)
// Create orchestrator session (unless --no-orchestrator or existing orchestrators found)
let tmuxTarget = sessionId;
let hasExistingOrchestrators = false;
let selectedOrchestratorId: string | null = null;

if (opts?.orchestrator !== false) {
const sm = await getSessionManager(config);

// Check for existing orchestrator sessions for this project
let allSessions;
try {
spinner.start("Creating orchestrator session");
const systemPrompt = generateOrchestratorPrompt({ config, projectId, project });
const session = await sm.spawnOrchestrator({ projectId, systemPrompt });
if (session.runtimeHandle?.id) {
tmuxTarget = session.runtimeHandle.id;
}
reused =
orchestratorSessionStrategy === "reuse" &&
session.metadata?.["orchestratorSessionReused"] === "true";
spinner.succeed(reused ? "Orchestrator session reused" : "Orchestrator session created");
allSessions = await sm.list(projectId);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The existence check only filters by isOrchestratorSession(), but sessionManager.list() returns terminal sessions too (killed, terminated). With --no-dashboard, the code auto-selects the "most recent" orchestrator at line 1021 and prints tmux attach -t ..., which can now point at a dead tmux target instead of spawning a fresh one.

A proper e2e test manually should have definitely caught it, I encourage you to try it out.

} catch (err) {
spinner.fail("Orchestrator setup failed");
spinner.fail("Failed to list sessions");
if (dashboardProcess) {
dashboardProcess.kill();
}
throw new Error(
`Failed to setup orchestrator: ${err instanceof Error ? err.message : String(err)}`,
`Failed to list sessions: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
const existingOrchestrators = allSessions.filter((s) =>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Missing allSessionPrefixes parameter.
isOrchestratorSession(s, prefix) without the third arg can false-positive in multi-project configs where one prefix is a substring of another (e.g. "app" matching "app-worker").

isOrchestratorSession(s, project.sessionPrefix ?? projectId),
);

if (existingOrchestrators.length > 0) {
// Existing orchestrators found
if (opts?.dashboard === false) {
// No dashboard — auto-select the most recently active orchestrator
const sortedOrchestrators = [...existingOrchestrators].sort(
(a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime(),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

lastActivityAt.getTime() will crash on null. Sessions with no activity yet have lastActivityAt as null.
Use b.lastActivityAt?.getTime() ?? 0

);
const selected = sortedOrchestrators[0];
selectedOrchestratorId = selected.id;
// Use runtimeHandle.id if available, otherwise fall back to the session ID
tmuxTarget = selected.runtimeHandle?.id ?? selected.id;
spinner.succeed(
`Using existing orchestrator session: ${selected.id}` +
(existingOrchestrators.length > 1
? ` (${existingOrchestrators.length - 1} other session(s) available)`
: ""),
);
} else {
// Dashboard available — let the user select
hasExistingOrchestrators = true;
spinner.info(
`Found ${existingOrchestrators.length} existing orchestrator session(s). ` +
`Open the dashboard to select or start a new one.`,
);
}
} else {
// No existing orchestrators — spawn a new one
try {
spinner.start("Creating orchestrator session");
const systemPrompt = generateOrchestratorPrompt({ config, projectId, project });
const session = await sm.spawnOrchestrator({ projectId, systemPrompt });
selectedOrchestratorId = session.id;
if (session.runtimeHandle?.id) {
tmuxTarget = session.runtimeHandle.id;
}
reused =
orchestratorSessionStrategy === "reuse" &&
session.metadata?.["orchestratorSessionReused"] === "true";
spinner.succeed(reused ? "Orchestrator session reused" : "Orchestrator session created");
} catch (err) {
spinner.fail("Orchestrator setup failed");
if (dashboardProcess) {
dashboardProcess.kill();
}
throw new Error(
`Failed to setup orchestrator: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
}
}

// Print summary
Expand All @@ -1030,29 +1081,39 @@ async function runStartup(
console.log(chalk.cyan("Lifecycle:"), lifecycleTarget);
}

if (opts?.orchestrator !== false && !reused) {
if (hasExistingOrchestrators) {
console.log(
chalk.cyan("Orchestrator:"),
"existing sessions found — select one in the dashboard",
);
} else if (opts?.orchestrator !== false && !reused) {
console.log(chalk.cyan("Orchestrator:"), `tmux attach -t ${tmuxTarget}`);
} else if (reused) {
console.log(chalk.cyan("Orchestrator:"), `reused existing session (${sessionId})`);
}

console.log(chalk.dim(`Config: ${config.configPath}`));

// Show next step hint
const projectIds = Object.keys(config.projects);
if (projectIds.length > 0) {
console.log(chalk.bold("\nNext step:\n"));
console.log(` Spawn an agent session:`);
console.log(chalk.cyan(` ao spawn <issue-number>\n`));
// Show next step hint (only if no existing orchestrators requiring selection)
if (!hasExistingOrchestrators) {
const projectIds = Object.keys(config.projects);
if (projectIds.length > 0) {
console.log(chalk.bold("\nNext step:\n"));
console.log(` Spawn an agent session:`);
console.log(chalk.cyan(` ao spawn <issue-number>\n`));
}
}

// Auto-open browser to orchestrator session page once the server is accepting connections.
// Auto-open browser to orchestrator session page (or selection page) once the server is ready.
// Polls the port instead of using a fixed delay — deterministic and works regardless of
// how long Next.js takes to compile. AbortController cancels polling on early exit.
let openAbort: AbortController | undefined;
if (opts?.dashboard !== false) {
openAbort = new AbortController();
const orchestratorUrl = `http://localhost:${port}/sessions/${sessionId}`;
// If existing orchestrators found, open the selection page; otherwise open the session page
const orchestratorUrl = hasExistingOrchestrators
? `http://localhost:${port}/orchestrators?project=${projectId}`
: `http://localhost:${port}/sessions/${selectedOrchestratorId ?? sessionId}`;
void waitForPortAndOpen(port, orchestratorUrl, openAbort.signal);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ describe("send", () => {
await sm.send("app-1", "confirm via updated timestamp");
const elapsedMs = Date.now() - startedAt;

expect(elapsedMs).toBeLessThan(2_000);
expect(elapsedMs).toBeLessThan(5_000);
expect(readFileSync(listLogPath, "utf-8").trim().split("\n").length).toBeGreaterThanOrEqual(2);
expect(mockRuntime.sendMessage).toHaveBeenCalledWith(
makeHandle("rt-1"),
Expand Down Expand Up @@ -442,7 +442,7 @@ describe("remap", () => {
expect(mapped).toBe("ses_slow_discovery");
const meta = readMetadataRaw(sessionsDir, "app-1");
expect(meta?.["opencodeSessionId"]).toBe("ses_slow_discovery");
});
}, 20000);

it("throws when OpenCode session id mapping is missing", async () => {
const deleteLogPath = join(tmpDir, "opencode-delete-missing-remap.log");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ describe("kill", () => {
await sm.kill("app-1", { purgeOpenCode: true });

expect(existsSync(deleteLogPath)).toBe(false);
});
}, 15000);
});

describe("cleanup", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ describe("restore", () => {
expect(restored.status).toBe("spawning");
const meta = readMetadataRaw(sessionsDir, "app-1");
expect(meta?.["opencodeSessionId"]).toBe("ses_restore_discovered");
});
}, 15000);

it("uses orchestratorModel when restoring orchestrator sessions", async () => {
const wsPath = join(tmpDir, "ws-app-orchestrator-restore");
Expand Down
48 changes: 47 additions & 1 deletion packages/web/src/__tests__/api-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ vi.mock("@/lib/services", () => ({
// ── Import routes after mocking ───────────────────────────────────────

import { GET as sessionsGET } from "@/app/api/sessions/route";
import { POST as orchestratorsPOST } from "@/app/api/orchestrators/route";
import { POST as orchestratorsPOST, GET as orchestratorsGET } from "@/app/api/orchestrators/route";
import { POST as spawnPOST } from "@/app/api/spawn/route";
import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route";
import { POST as messagePOST } from "@/app/api/sessions/[id]/message/route";
Expand Down Expand Up @@ -687,6 +687,52 @@ describe("API Routes", () => {
});
});

describe("GET /api/orchestrators", () => {
it("returns orchestrators for a project", async () => {
const orchestrator = makeSession({
id: "my-app-orchestrator",
projectId: "my-app",
metadata: { role: "orchestrator" },
});
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce([orchestrator]);

const res = await orchestratorsGET(
makeRequest("http://localhost:3000/api/orchestrators?project=my-app"),
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.orchestrators).toHaveLength(1);
expect(data.orchestrators[0].id).toBe("my-app-orchestrator");
expect(data.projectName).toBe("My App");
});

it("returns 400 when project parameter is missing", async () => {
const res = await orchestratorsGET(makeRequest("http://localhost:3000/api/orchestrators"));
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toMatch(/Missing project query parameter/);
});

it("returns 404 for unknown project", async () => {
const res = await orchestratorsGET(
makeRequest("http://localhost:3000/api/orchestrators?project=unknown-app"),
);
expect(res.status).toBe(404);
const data = await res.json();
expect(data.error).toMatch(/Unknown project/);
});

it("returns 500 when list fails", async () => {
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("boom"));
const res = await orchestratorsGET(
makeRequest("http://localhost:3000/api/orchestrators?project=my-app"),
);
expect(res.status).toBe(500);
const data = await res.json();
expect(data.error).toBe("boom");
});
});

// ── POST /api/sessions/:id/send ────────────────────────────────────

describe("POST /api/sessions/:id/send", () => {
Expand Down
Loading
Loading