fix(cli): derive projectId from prefixed issue id on spawn (#1330)
* fix(cli): derive projectId from prefixed issue id on spawn When `ao spawn <projectId>/<issue>` is used in a multi-project config, route the spawn to the prefixed project and strip the prefix from the issue id. Previously the projectId fell back to whichever project `ao start` was running for, tagging cross-project sessions with the wrong project (and session prefix). Applies to both `ao spawn` and `ao batch-spawn`; batch-spawn errors out if issues mix multiple project prefixes. Fixes #1329 * refactor(core,cli): lift spawn target resolution into core Move the issue-prefix → project routing from the CLI into a reusable core utility, `resolveSpawnTarget(projects, issueRef, fallback?)`. - Exposes routing to any spawn entry point (CLI, web API, programmatic). - Accepts either a project id or sessionPrefix as the prefix; project id wins on collision. - `ao batch-spawn` now groups issues by resolved project and preflights once per group instead of erroring on mixed prefixes. Tests: - 8 unit tests for `resolveSpawnTarget` in core. - CLI spawn.test.ts: adds a sessionPrefix routing test (23 tests total). Refs #1329 * test(cli): cover batch-spawn grouping; tighten resolveSpawnTarget API Self-review found three gaps: - `resolveSpawnTarget` accepted `undefined` issueRef and returned `{ issueId: "" }` with a fallback project. Dead path — both callers guard against undefined. Drop it and make issueRef required. - No tests for `batch-spawn`. Added two: - routes cross-project prefixed issues to the correct project and lists each project's sessions separately - skips a prefixed issue when the target project already has an active session for it - `spawn` and `batch-spawn` help text didn't document the `<projectId>/<issue>` or `<sessionPrefix>/<issue>` forms. * fix(core): guard resolveSpawnTarget against prototype-key matches Second review pass found a latent bug: Plain JS objects inherit `__proto__`, `constructor`, `toString`, `hasOwnProperty`, etc. from Object.prototype. The previous `if (projects[prefix])` check entered the routing branch for any of these keys, mis-routing a user typing `ao spawn __proto__/42` with `projectId: "__proto__"`. Downstream session-manager would then receive a junk project object (Object.prototype itself) and fail with a non-obvious error. Fix: use `Object.prototype.hasOwnProperty.call(projects, prefix)` so only actual configured project ids match. Also: - Document the case-sensitive matching semantic explicitly. - Lock in the batch-spawn grouping contract by asserting exact `list()` and `spawn()` call counts in the cross-project test.
This commit is contained in:
parent
331f1ce568
commit
616c56cea2
|
|
@ -71,7 +71,7 @@ let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|||
const STORAGE_KEY = "111111111113";
|
||||
|
||||
import { Command } from "commander";
|
||||
import { registerSpawn } from "../../src/commands/spawn.js";
|
||||
import { registerSpawn, registerBatchSpawn } from "../../src/commands/spawn.js";
|
||||
|
||||
let program: Command;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
|
@ -245,6 +245,140 @@ describe("spawn command", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("routes a <projectId>/<issue> identifier to the prefixed project", async () => {
|
||||
// Multi-project config where AO is running for the default project
|
||||
// but the issue belongs to a different project.
|
||||
(mockConfigRef.current as Record<string, unknown>).projects = {
|
||||
"agent-orchestrator": {
|
||||
name: "Agent Orchestrator",
|
||||
repo: "org/agent-orchestrator",
|
||||
path: join(tmpDir, "agent-orchestrator"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "ao",
|
||||
},
|
||||
"x402-identity": {
|
||||
name: "x402 Identity",
|
||||
repo: "harsh-batheja/x402-identity",
|
||||
path: join(tmpDir, "x402-identity"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "xid",
|
||||
},
|
||||
};
|
||||
mkdirSync(join(tmpDir, "agent-orchestrator"), { recursive: true });
|
||||
mkdirSync(join(tmpDir, "x402-identity"), { recursive: true });
|
||||
|
||||
// The cwd shouldn't change the result — prefix takes priority.
|
||||
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(join(tmpDir, "agent-orchestrator"));
|
||||
mockGetRunning.mockResolvedValue({
|
||||
pid: 1234,
|
||||
port: 3000,
|
||||
startedAt: "",
|
||||
projects: ["agent-orchestrator"],
|
||||
});
|
||||
|
||||
const fakeSession: Session = {
|
||||
id: "xid-1",
|
||||
projectId: "x402-identity",
|
||||
status: "spawning",
|
||||
activity: null,
|
||||
branch: "feat/issue-1",
|
||||
issueId: "1",
|
||||
pr: null,
|
||||
workspacePath: "/tmp/wt",
|
||||
runtimeHandle: { id: "hash-xid-1", runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
};
|
||||
mockSessionManager.spawn.mockResolvedValue(fakeSession);
|
||||
|
||||
await program.parseAsync(["node", "test", "spawn", "x402-identity/1"]);
|
||||
|
||||
expect(mockSessionManager.spawn).toHaveBeenCalledWith({
|
||||
projectId: "x402-identity",
|
||||
issueId: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes via sessionPrefix when that matches instead of project id", async () => {
|
||||
(mockConfigRef.current as Record<string, unknown>).projects = {
|
||||
"agent-orchestrator": {
|
||||
name: "Agent Orchestrator",
|
||||
repo: "org/agent-orchestrator",
|
||||
path: join(tmpDir, "agent-orchestrator"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "ao",
|
||||
},
|
||||
"x402-identity": {
|
||||
name: "x402 Identity",
|
||||
repo: "harsh-batheja/x402-identity",
|
||||
path: join(tmpDir, "x402-identity"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "xid",
|
||||
},
|
||||
};
|
||||
mkdirSync(join(tmpDir, "agent-orchestrator"), { recursive: true });
|
||||
mkdirSync(join(tmpDir, "x402-identity"), { recursive: true });
|
||||
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(join(tmpDir, "agent-orchestrator"));
|
||||
mockGetRunning.mockResolvedValue({
|
||||
pid: 1234,
|
||||
port: 3000,
|
||||
startedAt: "",
|
||||
projects: ["agent-orchestrator"],
|
||||
});
|
||||
|
||||
const fakeSession: Session = {
|
||||
id: "xid-2",
|
||||
projectId: "x402-identity",
|
||||
status: "spawning",
|
||||
activity: null,
|
||||
branch: "feat/issue-7",
|
||||
issueId: "7",
|
||||
pr: null,
|
||||
workspacePath: "/tmp/wt",
|
||||
runtimeHandle: { id: "hash-xid-2", runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
};
|
||||
mockSessionManager.spawn.mockResolvedValue(fakeSession);
|
||||
|
||||
await program.parseAsync(["node", "test", "spawn", "xid/7"]);
|
||||
|
||||
expect(mockSessionManager.spawn).toHaveBeenCalledWith({
|
||||
projectId: "x402-identity",
|
||||
issueId: "7",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the issueId untouched when the prefix is not a configured project", async () => {
|
||||
const fakeSession: Session = {
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "spawning",
|
||||
activity: null,
|
||||
branch: "feat/some-org-42",
|
||||
issueId: "some-org/42",
|
||||
pr: null,
|
||||
workspacePath: "/tmp/wt",
|
||||
runtimeHandle: { id: "hash-app-1", runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
};
|
||||
mockSessionManager.spawn.mockResolvedValue(fakeSession);
|
||||
|
||||
await program.parseAsync(["node", "test", "spawn", "some-org/42"]);
|
||||
|
||||
expect(mockSessionManager.spawn).toHaveBeenCalledWith({
|
||||
projectId: "my-app",
|
||||
issueId: "some-org/42",
|
||||
});
|
||||
});
|
||||
|
||||
it("spawns without issueId when none provided", async () => {
|
||||
const fakeSession: Session = {
|
||||
id: "app-1",
|
||||
|
|
@ -746,3 +880,142 @@ describe("spawn pre-flight checks", () => {
|
|||
expect(errors).not.toContain("not authenticated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("batch-spawn command", () => {
|
||||
function setupBatch(): Command {
|
||||
const cmd = new Command();
|
||||
cmd.exitOverride();
|
||||
registerBatchSpawn(cmd);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
function makeFakeSession(overrides: Partial<Session> & Pick<Session, "id" | "projectId">): Session {
|
||||
return {
|
||||
status: "spawning",
|
||||
activity: null,
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: "/tmp/wt",
|
||||
runtimeHandle: { id: `hash-${overrides.id}`, runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
...overrides,
|
||||
} as Session;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockSessionManager.list.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("groups cross-project issues and routes each to the correct project", async () => {
|
||||
(mockConfigRef.current as Record<string, unknown>).projects = {
|
||||
"agent-orchestrator": {
|
||||
name: "Agent Orchestrator",
|
||||
repo: "org/agent-orchestrator",
|
||||
path: join(tmpDir, "agent-orchestrator"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "ao",
|
||||
},
|
||||
"x402-identity": {
|
||||
name: "x402 Identity",
|
||||
repo: "harsh-batheja/x402-identity",
|
||||
path: join(tmpDir, "x402-identity"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "xid",
|
||||
},
|
||||
};
|
||||
mkdirSync(join(tmpDir, "agent-orchestrator"), { recursive: true });
|
||||
mkdirSync(join(tmpDir, "x402-identity"), { recursive: true });
|
||||
mockGetRunning.mockResolvedValue({
|
||||
pid: 1234,
|
||||
port: 3000,
|
||||
startedAt: "",
|
||||
projects: ["agent-orchestrator", "x402-identity"],
|
||||
});
|
||||
|
||||
mockSessionManager.spawn
|
||||
.mockResolvedValueOnce(makeFakeSession({ id: "ao-1", projectId: "agent-orchestrator" }))
|
||||
.mockResolvedValueOnce(makeFakeSession({ id: "xid-1", projectId: "x402-identity" }));
|
||||
|
||||
const program = setupBatch();
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"batch-spawn",
|
||||
"agent-orchestrator/10",
|
||||
"x402-identity/20",
|
||||
]);
|
||||
|
||||
const spawnCalls = mockSessionManager.spawn.mock.calls.map((call) => call[0]);
|
||||
expect(spawnCalls).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ projectId: "agent-orchestrator", issueId: "10" },
|
||||
{ projectId: "x402-identity", issueId: "20" },
|
||||
]),
|
||||
);
|
||||
expect(mockSessionManager.list).toHaveBeenCalledWith("agent-orchestrator");
|
||||
expect(mockSessionManager.list).toHaveBeenCalledWith("x402-identity");
|
||||
// Exactly one list() per project group — locks the grouping contract so a
|
||||
// regression that lists every project for every issue is caught.
|
||||
expect(mockSessionManager.list).toHaveBeenCalledTimes(2);
|
||||
expect(mockSessionManager.spawn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("skips a prefixed issue that already has an active session in the target project", async () => {
|
||||
(mockConfigRef.current as Record<string, unknown>).projects = {
|
||||
"agent-orchestrator": {
|
||||
name: "Agent Orchestrator",
|
||||
repo: "org/agent-orchestrator",
|
||||
path: join(tmpDir, "agent-orchestrator"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "ao",
|
||||
},
|
||||
"x402-identity": {
|
||||
name: "x402 Identity",
|
||||
repo: "harsh-batheja/x402-identity",
|
||||
path: join(tmpDir, "x402-identity"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "xid",
|
||||
},
|
||||
};
|
||||
mkdirSync(join(tmpDir, "agent-orchestrator"), { recursive: true });
|
||||
mkdirSync(join(tmpDir, "x402-identity"), { recursive: true });
|
||||
|
||||
// Pre-existing active session in x402-identity for issue 20
|
||||
mockSessionManager.list.mockImplementation(async (pid: string) => {
|
||||
if (pid === "x402-identity") {
|
||||
return [
|
||||
makeFakeSession({
|
||||
id: "xid-9",
|
||||
projectId: "x402-identity",
|
||||
status: "working",
|
||||
issueId: "20",
|
||||
}),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
mockSessionManager.spawn.mockResolvedValueOnce(
|
||||
makeFakeSession({ id: "ao-2", projectId: "agent-orchestrator" }),
|
||||
);
|
||||
|
||||
const program = setupBatch();
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"batch-spawn",
|
||||
"agent-orchestrator/10",
|
||||
"x402-identity/20",
|
||||
]);
|
||||
|
||||
expect(mockSessionManager.spawn).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionManager.spawn).toHaveBeenCalledWith({
|
||||
projectId: "agent-orchestrator",
|
||||
issueId: "10",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { Command } from "commander";
|
|||
import { resolve } from "node:path";
|
||||
import {
|
||||
loadConfig,
|
||||
resolveSpawnTarget,
|
||||
TERMINAL_STATUSES,
|
||||
type OrchestratorConfig,
|
||||
} from "@aoagents/ao-core";
|
||||
|
|
@ -191,7 +192,10 @@ export function registerSpawn(program: Command): void {
|
|||
program
|
||||
.command("spawn")
|
||||
.description("Spawn a single agent session")
|
||||
.argument("[first]", "Issue identifier (project is auto-detected)")
|
||||
.argument(
|
||||
"[first]",
|
||||
"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.",
|
||||
)
|
||||
.argument("[second]", "" /* hidden second arg to catch old two-arg usage */)
|
||||
.option("--open", "Open session in terminal tab")
|
||||
.option("--agent <name>", "Override the agent plugin (e.g. codex, claude-code)")
|
||||
|
|
@ -228,12 +232,18 @@ export function registerSpawn(program: Command): void {
|
|||
let issueId: string | undefined;
|
||||
|
||||
if (first) {
|
||||
issueId = first;
|
||||
try {
|
||||
projectId = autoDetectProject(config);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
||||
process.exit(1);
|
||||
const prefixed = resolveSpawnTarget(config.projects, first);
|
||||
if (prefixed) {
|
||||
projectId = prefixed.projectId;
|
||||
issueId = prefixed.issueId;
|
||||
} else {
|
||||
issueId = first;
|
||||
try {
|
||||
projectId = autoDetectProject(config);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No args: auto-detect project, no issue
|
||||
|
|
@ -272,97 +282,124 @@ export function registerBatchSpawn(program: Command): void {
|
|||
program
|
||||
.command("batch-spawn")
|
||||
.description("Spawn sessions for multiple issues with duplicate detection")
|
||||
.argument("<issues...>", "Issue identifiers (project is auto-detected)")
|
||||
.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();
|
||||
let projectId: string;
|
||||
|
||||
try {
|
||||
projectId = autoDetectProject(config);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
||||
process.exit(1);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.projects[projectId]) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Unknown project: ${projectId}\nAvailable: ${Object.keys(config.projects).join(", ")}`,
|
||||
),
|
||||
);
|
||||
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();
|
||||
console.log(` Project: ${chalk.bold(projectId)}`);
|
||||
console.log(` Issues: ${issues.join(", ")}`);
|
||||
for (const [pid, items] of groups) {
|
||||
console.log(
|
||||
` ${chalk.bold(pid)}: ${items.map((i) => i.original).join(", ")}`,
|
||||
);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Pre-flight once before the loop so a missing prerequisite fails fast
|
||||
try {
|
||||
await runSpawnPreflight(config, projectId);
|
||||
await warnIfAONotRunning(projectId);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sm = await getSessionManager(config);
|
||||
const created: Array<{ session: string; issue: string }> = [];
|
||||
const skipped: Array<{ issue: string; existing: string }> = [];
|
||||
const failed: Array<{ issue: string; error: string }> = [];
|
||||
const spawnedIssues = new Set<string>();
|
||||
|
||||
// Load existing sessions once before the loop to avoid repeated reads + enrichment.
|
||||
// Exclude terminal sessions so completed/merged sessions don't block respawning
|
||||
// (e.g. when an issue is reopened after its PR was merged).
|
||||
const existingSessions = await sm.list(projectId);
|
||||
const existingIssueMap = new Map(
|
||||
existingSessions
|
||||
.filter((s) => s.issueId && !TERMINAL_STATUSES.has(s.status))
|
||||
.map((s) => [s.issueId!.toLowerCase(), s.id]),
|
||||
);
|
||||
|
||||
for (const issue of issues) {
|
||||
// Duplicate detection — check both existing sessions and same-run duplicates
|
||||
if (spawnedIssues.has(issue.toLowerCase())) {
|
||||
console.log(chalk.yellow(` Skip ${issue} — duplicate in this batch`));
|
||||
skipped.push({ issue, existing: "(this batch)" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check existing sessions (pre-loaded before loop)
|
||||
const existingSessionId = existingIssueMap.get(issue.toLowerCase());
|
||||
if (existingSessionId) {
|
||||
console.log(chalk.yellow(` Skip ${issue} — already has session ${existingSessionId}`));
|
||||
skipped.push({ issue, existing: existingSessionId });
|
||||
continue;
|
||||
}
|
||||
const sm = await getSessionManager(config);
|
||||
|
||||
for (const [groupProjectId, items] of groups) {
|
||||
// Pre-flight once per project group so a missing prerequisite fails fast.
|
||||
try {
|
||||
const session = await sm.spawn({ projectId, issueId: issue });
|
||||
created.push({ session: session.id, issue });
|
||||
spawnedIssues.add(issue.toLowerCase());
|
||||
console.log(chalk.green(` Created ${session.id} for ${issue}`));
|
||||
|
||||
if (opts.open) {
|
||||
try {
|
||||
const tmuxTarget = session.runtimeHandle?.id ?? session.id;
|
||||
await exec("open-iterm-tab", [tmuxTarget]);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
await runSpawnPreflight(config, groupProjectId);
|
||||
await warnIfAONotRunning(groupProjectId);
|
||||
} catch (err) {
|
||||
failed.push({
|
||||
issue,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
console.log(
|
||||
chalk.red(` Failed ${issue} — ${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)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSpawnTarget } from "../spawn-target.js";
|
||||
import type { ProjectConfig } from "../types.js";
|
||||
|
||||
const baseProject: Omit<ProjectConfig, "name" | "path" | "sessionPrefix"> = {
|
||||
defaultBranch: "main",
|
||||
};
|
||||
|
||||
function makeProjects(): Record<string, ProjectConfig> {
|
||||
return {
|
||||
"agent-orchestrator": {
|
||||
...baseProject,
|
||||
name: "Agent Orchestrator",
|
||||
path: "/tmp/ao",
|
||||
sessionPrefix: "ao",
|
||||
},
|
||||
"x402-identity": {
|
||||
...baseProject,
|
||||
name: "x402 Identity",
|
||||
path: "/tmp/xid",
|
||||
sessionPrefix: "xid",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveSpawnTarget", () => {
|
||||
it("routes to the project matched by id prefix", () => {
|
||||
const target = resolveSpawnTarget(makeProjects(), "x402-identity/1");
|
||||
expect(target).toEqual({ projectId: "x402-identity", issueId: "1" });
|
||||
});
|
||||
|
||||
it("routes to the project matched by sessionPrefix", () => {
|
||||
const target = resolveSpawnTarget(makeProjects(), "xid/42");
|
||||
expect(target).toEqual({ projectId: "x402-identity", issueId: "42" });
|
||||
});
|
||||
|
||||
it("prefers project-id match over sessionPrefix collision", () => {
|
||||
const projects = makeProjects();
|
||||
// Give the other project a sessionPrefix that collides with the first project's id
|
||||
projects["x402-identity"].sessionPrefix = "agent-orchestrator";
|
||||
const target = resolveSpawnTarget(projects, "agent-orchestrator/9");
|
||||
expect(target).toEqual({ projectId: "agent-orchestrator", issueId: "9" });
|
||||
});
|
||||
|
||||
it("falls back to the fallback project when the prefix doesn't match", () => {
|
||||
const target = resolveSpawnTarget(makeProjects(), "some-org/42", "agent-orchestrator");
|
||||
expect(target).toEqual({ projectId: "agent-orchestrator", issueId: "some-org/42" });
|
||||
});
|
||||
|
||||
it("returns null without a fallback when nothing matches", () => {
|
||||
expect(resolveSpawnTarget(makeProjects(), "some-org/42")).toBeNull();
|
||||
});
|
||||
|
||||
it("treats a bare issue id as plain identifier", () => {
|
||||
const target = resolveSpawnTarget(makeProjects(), "INT-100", "x402-identity");
|
||||
expect(target).toEqual({ projectId: "x402-identity", issueId: "INT-100" });
|
||||
});
|
||||
|
||||
it("does not match inherited prototype keys", () => {
|
||||
// Regular plain objects inherit `__proto__`, `constructor`, `toString`, etc.
|
||||
// from Object.prototype — a truthy `projects[prefix]` check without hasOwn
|
||||
// would mis-route these.
|
||||
const projects = makeProjects();
|
||||
expect(resolveSpawnTarget(projects, "__proto__/42", "agent-orchestrator")).toEqual({
|
||||
projectId: "agent-orchestrator",
|
||||
issueId: "__proto__/42",
|
||||
});
|
||||
expect(resolveSpawnTarget(projects, "constructor/42", "agent-orchestrator")).toEqual({
|
||||
projectId: "agent-orchestrator",
|
||||
issueId: "constructor/42",
|
||||
});
|
||||
expect(resolveSpawnTarget(projects, "toString/42", "agent-orchestrator")).toEqual({
|
||||
projectId: "agent-orchestrator",
|
||||
issueId: "toString/42",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores leading-slash and trailing-slash inputs", () => {
|
||||
expect(resolveSpawnTarget(makeProjects(), "/42", "agent-orchestrator")).toEqual({
|
||||
projectId: "agent-orchestrator",
|
||||
issueId: "/42",
|
||||
});
|
||||
expect(resolveSpawnTarget(makeProjects(), "x402-identity/", "agent-orchestrator")).toEqual({
|
||||
projectId: "agent-orchestrator",
|
||||
issueId: "x402-identity/",
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -156,6 +156,8 @@ export {
|
|||
writeWorkspaceOpenCodeAgentsMd,
|
||||
} from "./opencode-agents-md.js";
|
||||
export { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
|
||||
export { resolveSpawnTarget } from "./spawn-target.js";
|
||||
export type { SpawnTarget } from "./spawn-target.js";
|
||||
|
||||
// Activity log — JSONL activity tracking for agents without native JSONL
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import type { ProjectConfig } from "./types.js";
|
||||
|
||||
export interface SpawnTarget {
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a possibly-prefixed issue reference into a `{ projectId, issueId }` pair.
|
||||
*
|
||||
* When the reference is of the form `<prefix>/<rest>` and `<prefix>` matches a
|
||||
* configured project id or `sessionPrefix`, the spawn is routed to that project
|
||||
* with `<rest>` as the issue id. Otherwise the reference is treated as a plain
|
||||
* issue id and the fallback project is used.
|
||||
*
|
||||
* - Matching is case-sensitive — yaml keys and `sessionPrefix` values are
|
||||
* compared literally.
|
||||
* - Exact project-id match wins over `sessionPrefix` match (yaml keys are
|
||||
* unique; `sessionPrefix` values are not guaranteed to be).
|
||||
* - Returns `null` when no prefix routing matches and no fallback is provided.
|
||||
* - Empty `<rest>` (trailing slash) is treated as no prefix — the full string
|
||||
* is passed through as the issue id.
|
||||
*/
|
||||
export function resolveSpawnTarget(
|
||||
projects: Record<string, ProjectConfig>,
|
||||
issueRef: string,
|
||||
fallbackProjectId?: string,
|
||||
): SpawnTarget | null {
|
||||
const slashIdx = issueRef.indexOf("/");
|
||||
if (slashIdx > 0 && slashIdx < issueRef.length - 1) {
|
||||
const prefix = issueRef.slice(0, slashIdx);
|
||||
const rest = issueRef.slice(slashIdx + 1);
|
||||
|
||||
// hasOwn guards against prototype keys (`__proto__`, `constructor`, …)
|
||||
// incorrectly matching via inheritance from Object.prototype.
|
||||
if (Object.prototype.hasOwnProperty.call(projects, prefix)) {
|
||||
return { projectId: prefix, issueId: rest };
|
||||
}
|
||||
for (const [pid, project] of Object.entries(projects)) {
|
||||
if (project.sessionPrefix === prefix) {
|
||||
return { projectId: pid, issueId: rest };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fallbackProjectId) return null;
|
||||
return { projectId: fallbackProjectId, issueId: issueRef };
|
||||
}
|
||||
Loading…
Reference in New Issue