543 lines
19 KiB
TypeScript
543 lines
19 KiB
TypeScript
import chalk from "chalk";
|
|
import ora from "ora";
|
|
import type { Command } from "commander";
|
|
import { resolve } from "node:path";
|
|
import {
|
|
loadConfig,
|
|
recordActivityEvent,
|
|
resolveSpawnTarget,
|
|
TERMINAL_STATUSES,
|
|
type OrchestratorConfig,
|
|
type PreflightContext,
|
|
} from "@aoagents/ao-core";
|
|
import { DEFAULT_PORT } from "../lib/constants.js";
|
|
import { exec } from "../lib/shell.js";
|
|
import { banner } from "../lib/format.js";
|
|
import { getPluginRegistry, getSessionManager } from "../lib/create-session-manager.js";
|
|
import { findProjectForDirectory } from "../lib/project-resolution.js";
|
|
import { getRunning } from "../lib/running-state.js";
|
|
import { projectSessionUrl } from "../lib/routes.js";
|
|
|
|
/**
|
|
* Auto-detect the project ID from the config.
|
|
* - If only one project exists, use it.
|
|
* - If multiple projects exist, match cwd against project paths.
|
|
* - Falls back to AO_PROJECT_ID env var (set when called from an agent session).
|
|
*/
|
|
function autoDetectProject(config: OrchestratorConfig): string {
|
|
const projectIds = Object.keys(config.projects);
|
|
if (projectIds.length === 0) {
|
|
throw new Error("No projects configured. Run 'ao start' first.");
|
|
}
|
|
if (projectIds.length === 1) {
|
|
return projectIds[0];
|
|
}
|
|
|
|
// Try AO_PROJECT_ID env var (set by AO when spawning agent sessions)
|
|
const envProject = process.env.AO_PROJECT_ID;
|
|
if (envProject && config.projects[envProject]) {
|
|
return envProject;
|
|
}
|
|
|
|
// Try matching cwd to a project path
|
|
const cwd = resolve(process.cwd());
|
|
const matchedProjectId = findProjectForDirectory(config.projects, cwd);
|
|
if (matchedProjectId) {
|
|
return matchedProjectId;
|
|
}
|
|
|
|
throw new Error(
|
|
`Multiple projects configured. Specify one: ${projectIds.join(", ")}\n` +
|
|
`Or run from within a project directory.`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Non-throwing variant — returns null when the project can't be resolved
|
|
* unambiguously. Used to feed `resolveSpawnTarget`'s fallback parameter so
|
|
* the prefix/no-prefix and issue/no-issue paths share one code path.
|
|
*/
|
|
function tryAutoDetectProject(config: OrchestratorConfig): string | null {
|
|
try {
|
|
return autoDetectProject(config);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the project + issue from a single optional CLI argument.
|
|
*
|
|
* Single source of truth for the four cases:
|
|
* - `ao spawn` → auto-detect project, no issue
|
|
* - `ao spawn 42` → auto-detect project, issue=42
|
|
* - `ao spawn xid/42` → prefix match, issue=42
|
|
* - `ao spawn x402-identity/42` → exact projectId match, issue=42
|
|
*
|
|
* Throws (via autoDetectProject) when the project can't be resolved — the
|
|
* caller wraps in one try/catch instead of duplicating it across branches.
|
|
*/
|
|
function resolveProjectAndIssue(
|
|
config: OrchestratorConfig,
|
|
issue: string | undefined,
|
|
): { projectId: string; issueId?: string } {
|
|
const fallback = tryAutoDetectProject(config);
|
|
if (issue) {
|
|
const target = resolveSpawnTarget(config.projects, issue, fallback ?? undefined);
|
|
if (target) return { projectId: target.projectId, issueId: target.issueId };
|
|
autoDetectProject(config); // throws with the real error message
|
|
throw new Error("unreachable");
|
|
}
|
|
if (!fallback) {
|
|
autoDetectProject(config); // throws
|
|
throw new Error("unreachable");
|
|
}
|
|
return { projectId: fallback };
|
|
}
|
|
|
|
interface SpawnClaimOptions {
|
|
claimPr?: string;
|
|
assignOnGithub?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Lifecycle polling runs in-process inside the long-lived `ao start` process.
|
|
* `ao spawn` is a one-shot CLI — it can't start polling in its own process
|
|
* (the interval would keep the CLI alive forever and duplicate work).
|
|
*
|
|
* Refuse to spawn if no `ao start` is running, or if the running instance is
|
|
* not polling this project. Without an active daemon, sessions get worktrees
|
|
* and tmux panes but no lifecycle reactions (CI-failure routing, review
|
|
* comments, revive transitions, event log). That silent blackout is a
|
|
* worse failure mode than creating no session at all — so fail fast with
|
|
* an actionable error.
|
|
*/
|
|
async function ensureAOPollingProject(projectId: string): Promise<void> {
|
|
const running = await getRunning();
|
|
if (!running) {
|
|
throw new Error(
|
|
`AO is not running — lifecycle polling is inactive. Run \`ao start\` before spawning sessions so they get CI/review routing and state advancement.`,
|
|
);
|
|
}
|
|
if (!running.projects.includes(projectId)) {
|
|
throw new Error(
|
|
`The running AO instance (pid ${running.pid}) is not polling project "${projectId}". Run \`ao start ${projectId}\` before spawning so sessions get tracked.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run pre-flight checks for a project once, before any sessions are spawned.
|
|
*
|
|
* Iterates the plugins selected for this spawn and calls each one's optional
|
|
* `preflight()`. Plugins own their own prerequisites (binary present, auth
|
|
* configured, etc.) so this CLI helper does not need to know which plugin
|
|
* needs which tool. Adding a new runtime/tracker/scm plugin only requires
|
|
* the plugin to declare its own preflight — no edits here.
|
|
*
|
|
* Collects every plugin's failure rather than aborting at the first one, so a
|
|
* user with multiple broken prerequisites (e.g. tmux missing AND gh logged
|
|
* out) sees both errors in a single run instead of fixing one and re-invoking.
|
|
*/
|
|
async function runSpawnPreflight(
|
|
config: OrchestratorConfig,
|
|
projectId: string,
|
|
options?: SpawnClaimOptions,
|
|
): Promise<void> {
|
|
const project = config.projects[projectId];
|
|
if (!project) return;
|
|
|
|
const ctx: PreflightContext = {
|
|
project,
|
|
intent: {
|
|
role: "worker",
|
|
willClaimExistingPR: !!options?.claimPr,
|
|
},
|
|
};
|
|
|
|
const registry = await getPluginRegistry(config);
|
|
// DefaultPluginsSchema (config.ts) defaults runtime/agent/workspace via
|
|
// .default(), so these are guaranteed strings — no literal fallback needed.
|
|
const runtimeName = project.runtime ?? config.defaults.runtime;
|
|
const agentName = project.agent ?? config.defaults.agent;
|
|
const workspaceName = project.workspace ?? config.defaults.workspace;
|
|
const trackerName = project.tracker?.plugin;
|
|
const scmName = project.scm?.plugin;
|
|
|
|
// Only iterate plugins that the spawn will actually exercise. SCM is
|
|
// skipped unless the user passed --claim-pr; otherwise an unconfigured
|
|
// gh auth would block spawns that don't touch PRs.
|
|
const candidates: Array<unknown> = [
|
|
registry.get("runtime", runtimeName),
|
|
registry.get("agent", agentName),
|
|
registry.get("workspace", workspaceName),
|
|
trackerName ? registry.get("tracker", trackerName) : null,
|
|
options?.claimPr && scmName ? registry.get("scm", scmName) : null,
|
|
];
|
|
|
|
const errors: Error[] = [];
|
|
for (const plugin of candidates) {
|
|
const preflight = (plugin as { preflight?: (ctx: PreflightContext) => Promise<void> } | null)
|
|
?.preflight;
|
|
if (!preflight) continue;
|
|
try {
|
|
await preflight.call(plugin, ctx);
|
|
} catch (err) {
|
|
errors.push(err instanceof Error ? err : new Error(String(err)));
|
|
}
|
|
}
|
|
|
|
if (errors.length === 1) throw errors[0];
|
|
if (errors.length > 1) {
|
|
throw new Error(
|
|
`${errors.length} preflight checks failed:\n` +
|
|
errors.map((e, i) => ` ${i + 1}. ${e.message}`).join("\n"),
|
|
);
|
|
}
|
|
}
|
|
|
|
async function spawnSession(
|
|
config: OrchestratorConfig,
|
|
projectId: string,
|
|
issueId?: string,
|
|
openTab?: boolean,
|
|
agent?: string,
|
|
claimOptions?: SpawnClaimOptions,
|
|
prompt?: string,
|
|
): Promise<void> {
|
|
const spinner = ora("Creating session").start();
|
|
|
|
try {
|
|
const sm = await getSessionManager(config);
|
|
spinner.text = "Spawning session via core";
|
|
|
|
// Validate and sanitize prompt (strip newlines to prevent metadata injection)
|
|
const sanitizedPrompt = prompt?.replace(/[\r\n]/g, " ").trim() || undefined;
|
|
if (sanitizedPrompt && sanitizedPrompt.length > 4096) {
|
|
throw new Error("Prompt must be at most 4096 characters");
|
|
}
|
|
|
|
recordActivityEvent({
|
|
projectId,
|
|
source: "cli",
|
|
kind: "cli.spawn_invoked",
|
|
level: "info",
|
|
summary: `ao spawn invoked${issueId ? ` for issue ${issueId}` : ""}`,
|
|
data: {
|
|
issueId: issueId ?? null,
|
|
agent: agent ?? null,
|
|
hasPrompt: !!sanitizedPrompt,
|
|
claimPr: claimOptions?.claimPr ?? null,
|
|
},
|
|
});
|
|
|
|
const session = await sm.spawn({
|
|
projectId,
|
|
issueId,
|
|
agent,
|
|
prompt: sanitizedPrompt,
|
|
});
|
|
|
|
let claimedPrUrl: string | null = null;
|
|
|
|
if (claimOptions?.claimPr) {
|
|
spinner.text = `Claiming PR ${claimOptions.claimPr}`;
|
|
try {
|
|
const claimResult = await sm.claimPR(session.id, claimOptions.claimPr, {
|
|
assignOnGithub: claimOptions.assignOnGithub,
|
|
});
|
|
claimedPrUrl = claimResult.pr.url;
|
|
} catch (err) {
|
|
throw new Error(
|
|
`Session ${session.id} was created, but failed to claim PR ${claimOptions.claimPr}: ${err instanceof Error ? err.message : String(err)}`,
|
|
{ cause: err },
|
|
);
|
|
}
|
|
}
|
|
|
|
const issueLabel = issueId ? ` for issue #${issueId}` : "";
|
|
const claimLabel = claimedPrUrl ? ` (claimed ${claimedPrUrl})` : "";
|
|
const port = config.port ?? DEFAULT_PORT;
|
|
spinner.succeed(`Session ${chalk.green(session.id)} spawned${issueLabel}${claimLabel}`);
|
|
console.log(` View: ${chalk.dim(projectSessionUrl(port, projectId, session.id))}`);
|
|
|
|
// Open terminal tab if requested
|
|
if (openTab) {
|
|
try {
|
|
const tmuxTarget = session.runtimeHandle?.id ?? session.id;
|
|
await exec("open-iterm-tab", [tmuxTarget]);
|
|
} catch {
|
|
// Terminal plugin not available
|
|
}
|
|
}
|
|
|
|
// Output for scripting
|
|
console.log(`SESSION=${session.id}`);
|
|
} catch (err) {
|
|
spinner.fail("Failed to create or initialize session");
|
|
recordActivityEvent({
|
|
projectId,
|
|
source: "cli",
|
|
kind: "cli.spawn_failed",
|
|
level: "error",
|
|
summary: `ao spawn failed${issueId ? ` for issue ${issueId}` : ""}`,
|
|
data: {
|
|
issueId: issueId ?? null,
|
|
agent: agent ?? null,
|
|
errorMessage: err instanceof Error ? err.message : String(err),
|
|
},
|
|
});
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export function registerSpawn(program: Command): void {
|
|
program
|
|
.command("spawn")
|
|
.description("Spawn a single agent session")
|
|
.argument(
|
|
"[issue]",
|
|
"Issue identifier. Accepts bare ids (42, INT-100) or prefixed forms (x402-identity/42, xid/42) to target a specific project by id or sessionPrefix.",
|
|
)
|
|
.allowExcessArguments()
|
|
.option("--open", "Open session in terminal tab")
|
|
.option("--agent <name>", "Override the agent plugin (e.g. codex, claude-code)")
|
|
.option("--claim-pr <pr>", "Immediately claim an existing PR for the spawned session")
|
|
.option("--assign-on-github", "Assign the claimed PR to the authenticated GitHub user")
|
|
.option(
|
|
"--prompt <text>",
|
|
"Initial prompt/instructions for the agent (use instead of an issue)",
|
|
)
|
|
.action(
|
|
async (
|
|
issue: string | undefined,
|
|
opts: {
|
|
open?: boolean;
|
|
agent?: string;
|
|
claimPr?: string;
|
|
assignOnGithub?: boolean;
|
|
prompt?: string;
|
|
},
|
|
command: Command,
|
|
) => {
|
|
if (command.args.length > 1) {
|
|
console.error(
|
|
chalk.red(
|
|
`✗ \`ao spawn\` accepts at most 1 argument, but ${command.args.length} were provided.\n\n` +
|
|
`Use:\n` +
|
|
` ao spawn [issue]`,
|
|
),
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const config = loadConfig();
|
|
let projectId: string;
|
|
let issueId: string | undefined;
|
|
try {
|
|
({ projectId, issueId } = resolveProjectAndIssue(config, issue));
|
|
} catch (err) {
|
|
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!opts.claimPr && opts.assignOnGithub) {
|
|
console.error(chalk.red("--assign-on-github requires --claim-pr on `ao spawn`."));
|
|
process.exit(1);
|
|
}
|
|
|
|
const claimOptions: SpawnClaimOptions = {
|
|
claimPr: opts.claimPr,
|
|
assignOnGithub: opts.assignOnGithub,
|
|
};
|
|
|
|
try {
|
|
await runSpawnPreflight(config, projectId, claimOptions);
|
|
await ensureAOPollingProject(projectId);
|
|
} catch (err) {
|
|
recordActivityEvent({
|
|
projectId,
|
|
source: "cli",
|
|
kind: "cli.spawn_failed",
|
|
level: "error",
|
|
summary: `ao spawn preflight failed${issueId ? ` for issue ${issueId}` : ""}`,
|
|
data: {
|
|
issueId: issueId ?? null,
|
|
agent: opts.agent ?? null,
|
|
claimPr: claimOptions.claimPr ?? null,
|
|
errorMessage: err instanceof Error ? err.message : String(err),
|
|
},
|
|
});
|
|
console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
await spawnSession(
|
|
config,
|
|
projectId,
|
|
issueId,
|
|
opts.open,
|
|
opts.agent,
|
|
claimOptions,
|
|
opts.prompt,
|
|
);
|
|
} catch (err) {
|
|
console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
|
process.exit(1);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
export function registerBatchSpawn(program: Command): void {
|
|
program
|
|
.command("batch-spawn")
|
|
.description("Spawn sessions for multiple issues with duplicate detection")
|
|
.argument(
|
|
"<issues...>",
|
|
"Issue identifiers. Accepts bare ids or prefixed forms (x402-identity/42, xid/42); mixed projects are grouped automatically.",
|
|
)
|
|
.option("--open", "Open sessions in terminal tabs")
|
|
.action(async (issues: string[], opts: { open?: boolean }) => {
|
|
const config = loadConfig();
|
|
|
|
// Resolve each issue to its target project. Issues without a prefix fall
|
|
// back to auto-detection; prefixed issues route to the matched project.
|
|
let fallbackProjectId: string | null = null;
|
|
const needsFallback = issues.some(
|
|
(issue) => resolveSpawnTarget(config.projects, issue) === null,
|
|
);
|
|
if (needsFallback) {
|
|
try {
|
|
fallbackProjectId = autoDetectProject(config);
|
|
} catch (err) {
|
|
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Group issues by resolved project so each group preflights once.
|
|
const groups = new Map<string, Array<{ original: string; resolved: string }>>();
|
|
for (const issue of issues) {
|
|
const target = resolveSpawnTarget(config.projects, issue, fallbackProjectId ?? undefined);
|
|
if (!target) {
|
|
console.error(chalk.red(`Could not resolve project for issue: ${issue}`));
|
|
process.exit(1);
|
|
}
|
|
if (!config.projects[target.projectId]) {
|
|
console.error(
|
|
chalk.red(
|
|
`Unknown project: ${target.projectId}\nAvailable: ${Object.keys(config.projects).join(", ")}`,
|
|
),
|
|
);
|
|
process.exit(1);
|
|
}
|
|
if (!groups.has(target.projectId)) groups.set(target.projectId, []);
|
|
groups.get(target.projectId)!.push({ original: issue, resolved: target.issueId });
|
|
}
|
|
|
|
console.log(banner("BATCH SESSION SPAWNER"));
|
|
console.log();
|
|
for (const [pid, items] of groups) {
|
|
console.log(` ${chalk.bold(pid)}: ${items.map((i) => i.original).join(", ")}`);
|
|
}
|
|
console.log();
|
|
|
|
const created: Array<{ session: string; issue: string }> = [];
|
|
const skipped: Array<{ issue: string; existing: string }> = [];
|
|
const failed: Array<{ issue: string; error: string }> = [];
|
|
|
|
const sm = await getSessionManager(config);
|
|
|
|
for (const [groupProjectId, items] of groups) {
|
|
// Pre-flight once per project group so a missing prerequisite fails fast.
|
|
try {
|
|
await runSpawnPreflight(config, groupProjectId);
|
|
await ensureAOPollingProject(groupProjectId);
|
|
} catch (err) {
|
|
recordActivityEvent({
|
|
projectId: groupProjectId,
|
|
source: "cli",
|
|
kind: "cli.spawn_failed",
|
|
level: "error",
|
|
summary: `batch-spawn preflight failed for group`,
|
|
data: {
|
|
batchSize: items.length,
|
|
errorMessage: err instanceof Error ? err.message : String(err),
|
|
},
|
|
});
|
|
console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
|
process.exit(1);
|
|
}
|
|
|
|
// Load existing sessions once per group (exclude terminal sessions so
|
|
// merged/completed sessions don't block respawning a reopened issue).
|
|
const existingSessions = await sm.list(groupProjectId);
|
|
const existingIssueMap = new Map(
|
|
existingSessions
|
|
.filter((s) => s.issueId && !TERMINAL_STATUSES.has(s.status))
|
|
.map((s) => [s.issueId!.toLowerCase(), s.id]),
|
|
);
|
|
const spawnedIssues = new Set<string>();
|
|
|
|
for (const { original, resolved } of items) {
|
|
if (spawnedIssues.has(resolved.toLowerCase())) {
|
|
console.log(chalk.yellow(` Skip ${original} — duplicate in this batch`));
|
|
skipped.push({ issue: original, existing: "(this batch)" });
|
|
continue;
|
|
}
|
|
const existingSessionId = existingIssueMap.get(resolved.toLowerCase());
|
|
if (existingSessionId) {
|
|
console.log(
|
|
chalk.yellow(` Skip ${original} — already has session ${existingSessionId}`),
|
|
);
|
|
skipped.push({ issue: original, existing: existingSessionId });
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const session = await sm.spawn({ projectId: groupProjectId, issueId: resolved });
|
|
created.push({ session: session.id, issue: original });
|
|
spawnedIssues.add(resolved.toLowerCase());
|
|
console.log(chalk.green(` Created ${session.id} for ${original}`));
|
|
|
|
if (opts.open) {
|
|
try {
|
|
const tmuxTarget = session.runtimeHandle?.id ?? session.id;
|
|
await exec("open-iterm-tab", [tmuxTarget]);
|
|
} catch {
|
|
// best effort
|
|
}
|
|
}
|
|
} catch (err) {
|
|
failed.push({
|
|
issue: original,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
console.log(
|
|
chalk.red(
|
|
` Failed ${original} — ${err instanceof Error ? err.message : String(err)}`,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log();
|
|
if (created.length > 0) {
|
|
console.log(chalk.green(`Created ${created.length} sessions:`));
|
|
for (const item of created) console.log(` ${item.session} ← ${item.issue}`);
|
|
}
|
|
if (skipped.length > 0) {
|
|
console.log(chalk.yellow(`Skipped ${skipped.length} issues:`));
|
|
for (const item of skipped) console.log(` ${item.issue} (existing: ${item.existing})`);
|
|
}
|
|
if (failed.length > 0) {
|
|
console.log(chalk.red(`Failed ${failed.length} issues:`));
|
|
for (const item of failed) console.log(` ${item.issue}: ${item.error}`);
|
|
}
|
|
console.log();
|
|
});
|
|
}
|