diff --git a/docs/CLI.md b/docs/CLI.md index 27411c11a7..04f54be1c8 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -25,15 +25,23 @@ These are primarily invoked by the orchestrator agent running inside a runtime s ```bash ao spawn [issue] # Spawn an agent (project auto-detected from cwd) ao spawn 123 --agent codex # Override agent for this session +ao spawn --claim-pr 42 --claim-pr-repo org/repo ao batch-spawn 101 102 103 # Spawn agents for multiple issues at once ao send "Fix the tests" # Send instructions to a running agent ao session ls # List active sessions (terminated hidden) ao session ls --include-terminated # Include killed/done/merged/errored/cleanup sessions ao session ls --json # Machine-readable session inventory (see note below) +ao session claim-pr 42 app-1 --repo org/repo ao session kill # Kill a session ao session restore # Revive a crashed agent ``` +`ao session claim-pr` accepts a bare PR number or a PR URL. When the PR lives +outside the configured project repository, pass `--repo owner/repo`; AO records +the replaced primary PR in `prHistory` metadata before switching the session to +the new PR. `ao spawn --claim-pr` supports the same repository override via +`--claim-pr-repo`. + > **JSON output:** `ao session ls --json` and `ao status --json` emit > `{ "data": [...], "meta": { "hiddenTerminatedCount": N } }`. Terminated sessions > (`killed`, `terminated`, `done`, `merged`, `errored`, `cleanup`) are filtered from diff --git a/packages/cli/__tests__/commands/session.test.ts b/packages/cli/__tests__/commands/session.test.ts index c2427836d7..66f9be4fe8 100644 --- a/packages/cli/__tests__/commands/session.test.ts +++ b/packages/cli/__tests__/commands/session.test.ts @@ -257,6 +257,7 @@ beforeEach(() => { baseBranch: "main", isDraft: false, }, + previousPr: null, branchChanged: true, githubAssigned: false, takenOverFrom: [], @@ -475,13 +476,7 @@ describe("session ls", () => { mockTmux.mockResolvedValue(null); mockGit.mockResolvedValue(null); - await program.parseAsync([ - "node", - "test", - "session", - "ls", - "--include-terminated", - ]); + await program.parseAsync(["node", "test", "session", "ls", "--include-terminated"]); const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n"); expect(output).toContain("app-1"); @@ -513,14 +508,7 @@ describe("session ls", () => { mockTmux.mockResolvedValue(null); mockGit.mockResolvedValue(null); - await program.parseAsync([ - "node", - "test", - "session", - "ls", - "--json", - "--include-terminated", - ]); + await program.parseAsync(["node", "test", "session", "ls", "--json", "--include-terminated"]); expect(consoleSpy).toHaveBeenCalledTimes(1); const parsed = JSON.parse(String(consoleSpy.mock.calls[0][0])); @@ -769,7 +757,11 @@ describe("session attach", () => { issueId: null, pr: null, workspacePath: null, - runtimeHandle: { id: "hash-app-1", runtimeName: "process", data: { pipePath: "\\\\.\\pipe\\ao-pty-hash-app-1" } }, + runtimeHandle: { + id: "hash-app-1", + runtimeName: "process", + data: { pipePath: "\\\\.\\pipe\\ao-pty-hash-app-1" }, + }, agentInfo: null, createdAt: new Date(), lastActivityAt: new Date(), @@ -803,7 +795,9 @@ describe("session attach", () => { const inputData = Buffer.from("ls\r"); process.stdin.emit("data", inputData); expect((mockSocket as { write: ReturnType }).write).toHaveBeenCalled(); - const written = (mockSocket as { write: ReturnType }).write.mock.calls.at(-1)![0] as Buffer; + const written = (mockSocket as { write: ReturnType }).write.mock.calls.at( + -1, + )![0] as Buffer; expect(written.readUInt8(0)).toBe(0x02); // MSG_TERMINAL_INPUT expect(written.subarray(5).toString()).toBe("ls\r"); @@ -939,7 +933,11 @@ describe("session attach", () => { issueId: null, pr: null, workspacePath: null, - runtimeHandle: { id: "hash-app-1", runtimeName: "process", data: { pipePath: "\\\\.\\pipe\\ao-pty-hash-app-1" } }, + runtimeHandle: { + id: "hash-app-1", + runtimeName: "process", + data: { pipePath: "\\\\.\\pipe\\ao-pty-hash-app-1" }, + }, agentInfo: null, createdAt: new Date(), lastActivityAt: new Date(), @@ -979,6 +977,7 @@ describe("session claim-pr", () => { expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-2", "42", { assignOnGithub: true, + repoOverride: undefined, }); const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n"); @@ -993,6 +992,25 @@ describe("session claim-pr", () => { expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-7", "42", { assignOnGithub: undefined, + repoOverride: undefined, + }); + }); + + it("passes --repo through to claimPR", async () => { + await program.parseAsync([ + "node", + "test", + "session", + "claim-pr", + "42", + "app-2", + "--repo", + "ComposioHQ/agent-orchestrator", + ]); + + expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-2", "42", { + assignOnGithub: undefined, + repoOverride: "ComposioHQ/agent-orchestrator", }); }); diff --git a/packages/cli/__tests__/commands/spawn.test.ts b/packages/cli/__tests__/commands/spawn.test.ts index 84e076ce94..d7c0131dc8 100644 --- a/packages/cli/__tests__/commands/spawn.test.ts +++ b/packages/cli/__tests__/commands/spawn.test.ts @@ -576,6 +576,7 @@ describe("spawn command", () => { baseBranch: "main", isDraft: false, }, + previousPr: null, branchChanged: true, githubAssigned: false, takenOverFrom: [], @@ -590,6 +591,7 @@ describe("spawn command", () => { }); expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-1", "123", { assignOnGithub: undefined, + repoOverride: undefined, }); const succeedMsg = String(mockSpinner.succeed.mock.calls[0]?.[0] ?? ""); @@ -629,18 +631,84 @@ describe("spawn command", () => { baseBranch: "main", isDraft: false, }, + previousPr: null, branchChanged: true, githubAssigned: true, takenOverFrom: ["app-9"], }); await program.parseAsync(["node", "test", "spawn", "--claim-pr", "123", "--assign-on-github"]); + expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-1", "123", { + assignOnGithub: true, + repoOverride: undefined, + }); + }); + + it("passes --claim-pr-repo through to claimPR", async () => { + const fakeSession: Session = { + id: "app-1", + projectId: "my-app", + status: "spawning", + activity: null, + branch: null, + issueId: null, + 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); + mockSessionManager.claimPR.mockResolvedValue({ + sessionId: "app-1", + projectId: "my-app", + pr: { + number: 123, + url: "https://github.com/org/repo/pull/123", + title: "Existing PR", + owner: "org", + repo: "repo", + branch: "feat/claimed-pr", + baseBranch: "main", + isDraft: false, + }, + previousPr: null, + branchChanged: true, + githubAssigned: false, + takenOverFrom: [], + }); + + await program.parseAsync([ + "node", + "test", + "spawn", + "--claim-pr", + "123", + "--claim-pr-repo", + "ComposioHQ/agent-orchestrator", + ]); expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-1", "123", { - assignOnGithub: true, + assignOnGithub: undefined, + repoOverride: "ComposioHQ/agent-orchestrator", }); }); + it("rejects --claim-pr-repo without --claim-pr", async () => { + await expect( + program.parseAsync(["node", "test", "spawn", "--claim-pr-repo", "org/repo"]), + ).rejects.toThrow("process.exit(1)"); + + const errors = vi + .mocked(console.error) + .mock.calls.map((c) => String(c[0])) + .join("\n"); + expect(errors).toContain("--claim-pr-repo requires --claim-pr"); + }); + it("rejects --assign-on-github without --claim-pr", async () => { await expect( program.parseAsync(["node", "test", "spawn", "--assign-on-github"]), @@ -803,6 +871,7 @@ describe("spawn pre-flight checks", () => { baseBranch: "main", isDraft: false, }, + previousPr: null, branchChanged: true, githubAssigned: false, takenOverFrom: [], @@ -1015,9 +1084,7 @@ describe("spawn daemon-polling enforcement", () => { it("refuses to spawn when no AO daemon is running", async () => { mockGetRunning.mockResolvedValue(null); - await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow( - "process.exit(1)", - ); + await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow("process.exit(1)"); const errors = vi .mocked(console.error) @@ -1036,9 +1103,7 @@ describe("spawn daemon-polling enforcement", () => { projects: ["other-project"], }); - await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow( - "process.exit(1)", - ); + await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow("process.exit(1)"); const errors = vi .mocked(console.error) diff --git a/packages/cli/src/commands/session.ts b/packages/cli/src/commands/session.ts index 804b6c2ae5..7620c3e817 100644 --- a/packages/cli/src/commands/session.ts +++ b/packages/cli/src/commands/session.ts @@ -46,156 +46,152 @@ export function registerSession(program: Command): void { "Include terminated sessions (killed/done/merged/terminated/errored/cleanup)", ) .option("--json", "Output as JSON") - .action(async (opts: { - project?: string; - all?: boolean; - includeTerminated?: boolean; - json?: boolean; - }) => { - const config = loadConfig(); - if (opts.project && !config.projects[opts.project]) { - console.error(chalk.red(`Unknown project: ${opts.project}`)); - process.exit(1); - } - - const sm = await getSessionManager(config); - const allSessions = await sm.list(opts.project); - - // Filter out orchestrator sessions unless --all is passed - const withoutOrchestrators = opts.all - ? allSessions - : allSessions.filter( - (s) => !isOrchestratorSessionName(config, s.id, s.projectId), - ); - - // Count terminal sessions that would be hidden by default, then - // drop them unless --include-terminated is passed. - const hiddenTerminatedCount = opts.includeTerminated - ? 0 - : withoutOrchestrators.filter(isTerminalSession).length; - const sessions = opts.includeTerminated - ? withoutOrchestrators - : withoutOrchestrators.filter((s) => !isTerminalSession(s)); - - // Group sessions by project - const byProject = new Map(); - for (const s of sessions) { - const list = byProject.get(s.projectId) ?? []; - list.push(s); - byProject.set(s.projectId, list); - } + .action( + async (opts: { + project?: string; + all?: boolean; + includeTerminated?: boolean; + json?: boolean; + }) => { + const config = loadConfig(); + if (opts.project && !config.projects[opts.project]) { + console.error(chalk.red(`Unknown project: ${opts.project}`)); + process.exit(1); + } - // Iterate over all configured projects (not just ones with sessions) - const projectIds = opts.project ? [opts.project] : Object.keys(config.projects); - const allSessionPrefixes = Object.entries(config.projects).map( - ([id, project]) => project.sessionPrefix ?? id, - ); - const jsonOutput: SessionListEntry[] = []; - - for (const projectId of projectIds) { - const project = config.projects[projectId]; - if (!project) continue; - if (!opts.json) { - console.log(chalk.bold(`\n${project.name || projectId}:`)); + const sm = await getSessionManager(config); + const allSessions = await sm.list(opts.project); + + // Filter out orchestrator sessions unless --all is passed + const withoutOrchestrators = opts.all + ? allSessions + : allSessions.filter((s) => !isOrchestratorSessionName(config, s.id, s.projectId)); + + // Count terminal sessions that would be hidden by default, then + // drop them unless --include-terminated is passed. + const hiddenTerminatedCount = opts.includeTerminated + ? 0 + : withoutOrchestrators.filter(isTerminalSession).length; + const sessions = opts.includeTerminated + ? withoutOrchestrators + : withoutOrchestrators.filter((s) => !isTerminalSession(s)); + + // Group sessions by project + const byProject = new Map(); + for (const s of sessions) { + const list = byProject.get(s.projectId) ?? []; + list.push(s); + byProject.set(s.projectId, list); } - const projectSessions = (byProject.get(projectId) ?? []).sort((a, b) => - a.id.localeCompare(b.id), + // Iterate over all configured projects (not just ones with sessions) + const projectIds = opts.project ? [opts.project] : Object.keys(config.projects); + const allSessionPrefixes = Object.entries(config.projects).map( + ([id, project]) => project.sessionPrefix ?? id, ); + const jsonOutput: SessionListEntry[] = []; - if (projectSessions.length === 0) { + for (const projectId of projectIds) { + const project = config.projects[projectId]; + if (!project) continue; if (!opts.json) { - console.log(chalk.dim(" (no active sessions)")); + console.log(chalk.bold(`\n${project.name || projectId}:`)); } - continue; - } - // Pre-fetch all branches and activities in parallel - const branches = await Promise.all( - projectSessions.map(async (s) => { - if (s.workspacePath) { - return git(["branch", "--show-current"], s.workspacePath).catch(() => null); - } - return null; - }), - ); + const projectSessions = (byProject.get(projectId) ?? []).sort((a, b) => + a.id.localeCompare(b.id), + ); - const activities = await Promise.all( - projectSessions.map((s) => { - // On Windows, use enriched session lastActivityAt (no tmux available). - if (isWindows()) { - return Promise.resolve(s.lastActivityAt ? s.lastActivityAt.getTime() : null); + if (projectSessions.length === 0) { + if (!opts.json) { + console.log(chalk.dim(" (no active sessions)")); } - const tmuxTarget = s.runtimeHandle?.id ?? s.id; - return getTmuxActivity(tmuxTarget).catch(() => null); - }), - ); - - for (let i = 0; i < projectSessions.length; i++) { - const s = projectSessions[i]; - const liveBranch = branches[i]; - const activityTs = activities[i]; - - // Priority: live branch from workspace > metadata branch > empty string - const branchStr = (s.workspacePath && liveBranch) ? liveBranch : (s.branch || ""); - const prUrl = s.metadata["pr"] ?? null; - - if (opts.json) { - const role = isOrchestratorSession( - s, - project.sessionPrefix ?? projectId, - allSessionPrefixes, - ) - ? "orchestrator" - : "worker"; - - jsonOutput.push({ - id: s.id, - projectId, - projectName: project.name || projectId, - role, - branch: branchStr || null, - status: s.status, - issueId: s.issueId, - pr: prUrl, - workspacePath: s.workspacePath, - lastActivityAt: activityTs ? new Date(activityTs).toISOString() : null, - }); - continue; } - const age = activityTs ? formatAge(activityTs) : "-"; - const parts = [chalk.green(s.id), chalk.dim(`(${age})`)]; - if (branchStr) parts.push(chalk.cyan(branchStr)); - if (s.status) parts.push(chalk.dim(`[${s.status}]`)); - if (prUrl) parts.push(chalk.blue(prUrl)); + // Pre-fetch all branches and activities in parallel + const branches = await Promise.all( + projectSessions.map(async (s) => { + if (s.workspacePath) { + return git(["branch", "--show-current"], s.workspacePath).catch(() => null); + } + return null; + }), + ); - console.log(` ${parts.join(" ")}`); + const activities = await Promise.all( + projectSessions.map((s) => { + // On Windows, use enriched session lastActivityAt (no tmux available). + if (isWindows()) { + return Promise.resolve(s.lastActivityAt ? s.lastActivityAt.getTime() : null); + } + const tmuxTarget = s.runtimeHandle?.id ?? s.id; + return getTmuxActivity(tmuxTarget).catch(() => null); + }), + ); + + for (let i = 0; i < projectSessions.length; i++) { + const s = projectSessions[i]; + const liveBranch = branches[i]; + const activityTs = activities[i]; + + // Priority: live branch from workspace > metadata branch > empty string + const branchStr = s.workspacePath && liveBranch ? liveBranch : s.branch || ""; + const prUrl = s.metadata["pr"] ?? null; + + if (opts.json) { + const role = isOrchestratorSession( + s, + project.sessionPrefix ?? projectId, + allSessionPrefixes, + ) + ? "orchestrator" + : "worker"; + + jsonOutput.push({ + id: s.id, + projectId, + projectName: project.name || projectId, + role, + branch: branchStr || null, + status: s.status, + issueId: s.issueId, + pr: prUrl, + workspacePath: s.workspacePath, + lastActivityAt: activityTs ? new Date(activityTs).toISOString() : null, + }); + + continue; + } + + const age = activityTs ? formatAge(activityTs) : "-"; + const parts = [chalk.green(s.id), chalk.dim(`(${age})`)]; + if (branchStr) parts.push(chalk.cyan(branchStr)); + if (s.status) parts.push(chalk.dim(`[${s.status}]`)); + if (prUrl) parts.push(chalk.blue(prUrl)); + + console.log(` ${parts.join(" ")}`); + } } - } - if (opts.json) { - console.log( - JSON.stringify( - { data: jsonOutput, meta: { hiddenTerminatedCount } }, - null, - 2, - ), - ); - return; - } + if (opts.json) { + console.log( + JSON.stringify({ data: jsonOutput, meta: { hiddenTerminatedCount } }, null, 2), + ); + return; + } - if (hiddenTerminatedCount > 0) { - console.log( - chalk.dim( - ` ${hiddenTerminatedCount} terminated session${hiddenTerminatedCount !== 1 ? "s" : ""} hidden. Use --include-terminated to show.`, - ), - ); - } + if (hiddenTerminatedCount > 0) { + console.log( + chalk.dim( + ` ${hiddenTerminatedCount} terminated session${hiddenTerminatedCount !== 1 ? "s" : ""} hidden. Use --include-terminated to show.`, + ), + ); + } - console.log(); - }); + console.log(); + }, + ); session .command("attach") @@ -210,14 +206,15 @@ export function registerSession(program: Command): void { // Windows: connect to PTY host named pipe and relay raw terminal I/O // Prefer explicit pipePath from runtimeHandle.data if it's a valid string const dataPipePath = sessionInfo?.runtimeHandle?.data?.["pipePath"]; - const pipePath = typeof dataPipePath === "string" && dataPipePath - ? dataPipePath - : `\\\\.\\pipe\\ao-pty-${ - sessionInfo?.runtimeHandle?.id ?? - (config.configPath - ? `${generateConfigHash(config.configPath)}-${sessionName}` - : sessionName) - }`; + const pipePath = + typeof dataPipePath === "string" && dataPipePath + ? dataPipePath + : `\\\\.\\pipe\\ao-pty-${ + sessionInfo?.runtimeHandle?.id ?? + (config.configPath + ? `${generateConfigHash(config.configPath)}-${sessionName}` + : sessionName) + }`; const sock = netConnect(pipePath); @@ -265,13 +262,18 @@ export function registerSession(program: Command): void { // 0x07 = MSG_STATUS_RES (PTY exited) if (msgType === 0x07) { try { - const status = JSON.parse(payload.toString()) as { alive: boolean; exitCode?: number }; + const status = JSON.parse(payload.toString()) as { + alive: boolean; + exitCode?: number; + }; if (!status.alive) { cleanup(); console.log(`\n[session exited with code ${status.exitCode ?? "unknown"}]`); process.exit(status.exitCode ?? 0); } - } catch { /* ignore parse errors */ } + } catch { + /* ignore parse errors */ + } } } }); @@ -451,12 +453,13 @@ export function registerSession(program: Command): void { .description("Attach an existing PR to a session") .argument("", "Pull request number or URL") .argument("[session]", "Session name (defaults to AO_SESSION_NAME/AO_SESSION)") + .option("--repo ", "Resolve a bare PR number against this repository") .option("--assign-on-github", "Assign the PR to the authenticated GitHub user") .action( async ( prRef: string, sessionName: string | undefined, - opts: { assignOnGithub?: boolean }, + opts: { assignOnGithub?: boolean; repo?: string }, ) => { const config = loadConfig(); const resolvedSession = @@ -476,11 +479,15 @@ export function registerSession(program: Command): void { try { const result = await sm.claimPR(resolvedSession, prRef, { assignOnGithub: opts.assignOnGithub, + repoOverride: opts.repo, }); console.log(chalk.green(`\nSession ${resolvedSession} claimed PR #${result.pr.number}.`)); console.log(chalk.dim(` PR: ${result.pr.url}`)); console.log(chalk.dim(` Branch: ${result.pr.branch}`)); + if (result.previousPr) { + console.log(chalk.dim(` Previous: ${result.previousPr.url}`)); + } console.log( chalk.dim( ` Checkout: ${result.branchChanged ? "switched to PR branch" : "already on PR branch"}`, @@ -521,7 +528,9 @@ export function registerSession(program: Command): void { console.log(chalk.dim(` Branch: ${restored.branch}`)); } const port = config.port ?? DEFAULT_PORT; - console.log(chalk.dim(` View: ${projectSessionUrl(port, restored.projectId, sessionName)}`)); + console.log( + chalk.dim(` View: ${projectSessionUrl(port, restored.projectId, sessionName)}`), + ); } catch (err) { if (err instanceof SessionNotRestorableError) { console.error(chalk.red(`Cannot restore: ${err.reason}`)); diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index cb2730b43b..2c6257428c 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -97,6 +97,7 @@ function resolveProjectAndIssue( interface SpawnClaimOptions { claimPr?: string; + claimPrRepo?: string; assignOnGithub?: boolean; } @@ -245,6 +246,7 @@ async function spawnSession( try { const claimResult = await sm.claimPR(session.id, claimOptions.claimPr, { assignOnGithub: claimOptions.assignOnGithub, + repoOverride: claimOptions.claimPrRepo, }); claimedPrUrl = claimResult.pr.url; } catch (err) { @@ -303,6 +305,7 @@ export function registerSpawn(program: Command): void { .option("--open", "Open session in terminal tab") .option("--agent ", "Override the agent plugin (e.g. codex, claude-code)") .option("--claim-pr ", "Immediately claim an existing PR for the spawned session") + .option("--claim-pr-repo ", "Resolve --claim-pr against this repository") .option("--assign-on-github", "Assign the claimed PR to the authenticated GitHub user") .option( "--prompt ", @@ -315,6 +318,7 @@ export function registerSpawn(program: Command): void { open?: boolean; agent?: string; claimPr?: string; + claimPrRepo?: string; assignOnGithub?: boolean; prompt?: string; }, @@ -345,9 +349,14 @@ export function registerSpawn(program: Command): void { console.error(chalk.red("--assign-on-github requires --claim-pr on `ao spawn`.")); process.exit(1); } + if (!opts.claimPr && opts.claimPrRepo) { + console.error(chalk.red("--claim-pr-repo requires --claim-pr on `ao spawn`.")); + process.exit(1); + } const claimOptions: SpawnClaimOptions = { claimPr: opts.claimPr, + claimPrRepo: opts.claimPrRepo, assignOnGithub: opts.assignOnGithub, }; diff --git a/packages/core/src/__tests__/metadata.test.ts b/packages/core/src/__tests__/metadata.test.ts index 47c66b0e00..b907a4d007 100644 --- a/packages/core/src/__tests__/metadata.test.ts +++ b/packages/core/src/__tests__/metadata.test.ts @@ -91,6 +91,21 @@ describe("writeMetadata + readMetadata", () => { expect(meta!.lifecycle?.version).toBe(2); }); + it("reads prHistory when written", () => { + const history = JSON.stringify([{ url: "https://github.com/org/repo/pull/1", replacedAt: "t" }]); + writeMetadata(dataDir, "app-pr-hist", { + worktree: "/tmp/w", + branch: "main", + status: "working", + pr: "https://github.com/org/repo/pull/2", + prHistory: history, + }); + + const meta = readMetadata(dataDir, "app-pr-hist"); + expect(meta).not.toBeNull(); + expect(meta!.prHistory).toBe(history); + }); + it("returns null for nonexistent session", () => { const meta = readMetadata(dataDir, "nonexistent"); expect(meta).toBeNull(); diff --git a/packages/core/src/__tests__/session-manager/claim-pr.test.ts b/packages/core/src/__tests__/session-manager/claim-pr.test.ts index 81db7b0c3e..62ef910f33 100644 --- a/packages/core/src/__tests__/session-manager/claim-pr.test.ts +++ b/packages/core/src/__tests__/session-manager/claim-pr.test.ts @@ -1,7 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { - utimesSync, -} from "node:fs"; +import { utimesSync } from "node:fs"; import { join } from "node:path"; import { createSessionManager } from "../../session-manager.js"; import { @@ -17,7 +15,12 @@ import { type Workspace, type SCM, } from "../../types.js"; -import { setupTestContext, teardownTestContext, makeHandle, type TestContext } from "../test-utils.js"; +import { + setupTestContext, + teardownTestContext, + makeHandle, + type TestContext, +} from "../test-utils.js"; let ctx: TestContext; let sessionsDir: string; @@ -109,6 +112,134 @@ describe("claimPR", () => { expect(raw!["prAutoDetect"]).toBeUndefined(); }); + it("resolves full PR URLs against the URL repository", async () => { + const mockSCM = makeSCM(); + + writeMetadata(sessionsDir, "app-2", { + worktree: "/tmp/ws-app-2", + branch: "feat/old-branch", + status: "working", + project: "my-app", + runtimeHandle: makeHandle("rt-2"), + }); + + const sm = createSessionManager({ config, registry: registryWithSCM(mockSCM) }); + await sm.claimPR("app-2", "https://github.com/ComposioHQ/agent-orchestrator/pull/1618"); + + expect(mockSCM.resolvePR).toHaveBeenCalledWith( + "1618", + expect.objectContaining({ repo: "ComposioHQ/agent-orchestrator" }), + ); + }); + + it("resolves bare PR numbers against an explicit repo override", async () => { + const mockSCM = makeSCM(); + + writeMetadata(sessionsDir, "app-2", { + worktree: "/tmp/ws-app-2", + branch: "feat/old-branch", + status: "working", + project: "my-app", + runtimeHandle: makeHandle("rt-2"), + }); + + const sm = createSessionManager({ config, registry: registryWithSCM(mockSCM) }); + await sm.claimPR("app-2", "42", { repoOverride: "ComposioHQ/agent-orchestrator" }); + + expect(mockSCM.resolvePR).toHaveBeenCalledWith( + "42", + expect.objectContaining({ repo: "ComposioHQ/agent-orchestrator" }), + ); + }); + + it("rejects invalid repo overrides before calling the SCM plugin", async () => { + const mockSCM = makeSCM(); + + writeMetadata(sessionsDir, "app-2", { + worktree: "/tmp/ws-app-2", + branch: "feat/old-branch", + status: "working", + project: "my-app", + runtimeHandle: makeHandle("rt-2"), + }); + + const sm = createSessionManager({ config, registry: registryWithSCM(mockSCM) }); + await expect(sm.claimPR("app-2", "42", { repoOverride: "not a repo" })).rejects.toThrow( + 'Invalid repo "not a repo"', + ); + expect(mockSCM.resolvePR).not.toHaveBeenCalled(); + }); + + it("rejects repo overrides with more than owner/repo segments", async () => { + const mockSCM = makeSCM(); + + writeMetadata(sessionsDir, "app-2", { + worktree: "/tmp/ws-app-2", + branch: "feat/old-branch", + status: "working", + project: "my-app", + runtimeHandle: makeHandle("rt-2"), + }); + + const sm = createSessionManager({ config, registry: registryWithSCM(mockSCM) }); + await expect( + sm.claimPR("app-2", "42", { repoOverride: "org/repo/extra" }), + ).rejects.toThrow('Invalid repo "org/repo/extra"'); + expect(mockSCM.resolvePR).not.toHaveBeenCalled(); + }); + + it("does not strip local sessions on branch name alone when claiming a cross-repo PR", async () => { + const mockSCM = makeSCM({ + resolvePR: vi.fn().mockImplementation((_ref: string, proj: { repo?: string }) => { + if (proj.repo === "other/external") { + return Promise.resolve({ + number: 99, + url: "https://github.com/other/external/pull/99", + title: "External PR", + owner: "other", + repo: "external", + branch: "main", + baseBranch: "main", + isDraft: false, + }); + } + return Promise.resolve({ + number: 42, + url: "https://github.com/org/my-app/pull/42", + title: "Existing PR", + owner: "org", + repo: "my-app", + branch: "feat/existing-pr", + baseBranch: "main", + isDraft: false, + }); + }), + }); + + writeMetadata(sessionsDir, "app-local", { + worktree: "/tmp/ws-local", + branch: "main", + status: "working", + project: "my-app", + pr: "https://github.com/org/my-app/pull/5", + runtimeHandle: makeHandle("rt-local"), + }); + + writeMetadata(sessionsDir, "app-claimer", { + worktree: "/tmp/ws-claim", + branch: "develop", + status: "working", + project: "my-app", + runtimeHandle: makeHandle("rt-claim"), + }); + + const sm = createSessionManager({ config, registry: registryWithSCM(mockSCM) }); + const result = await sm.claimPR("app-claimer", "99", { repoOverride: "other/external" }); + + expect(result.takenOverFrom).toEqual([]); + expect(readMetadataRaw(sessionsDir, "app-local")!["pr"]).toBe("https://github.com/org/my-app/pull/5"); + }); + it("consolidates ownership by disabling PR auto-detect on the previous session", async () => { const mockSCM = makeSCM(); @@ -385,6 +516,20 @@ describe("claimPR", () => { raw = readMetadataRaw(sessionsDir, "app-1"); expect(raw!["pr"]).toBe("https://github.com/org/my-app/pull/99"); expect(raw!["branch"]).toBe("feat/second-pr"); + expect(JSON.parse(raw!["prHistory"]!)).toEqual([ + expect.objectContaining({ + url: "https://github.com/org/my-app/pull/42", + number: 42, + branch: "feat/first-pr", + }), + ]); + expect(result2.previousPr).toEqual( + expect.objectContaining({ + url: "https://github.com/org/my-app/pull/42", + number: 42, + branch: "feat/first-pr", + }), + ); }); // Idempotent re-claim by same owner diff --git a/packages/core/src/__tests__/test-utils.ts b/packages/core/src/__tests__/test-utils.ts index 24f209f0f1..e5ec0460d3 100644 --- a/packages/core/src/__tests__/test-utils.ts +++ b/packages/core/src/__tests__/test-utils.ts @@ -519,6 +519,7 @@ export function createMockSessionManager(): OpenCodeSessionManager { sessionId: "app-1", projectId: "my-app", pr: makePR(), + previousPr: null, branchChanged: false, githubAssigned: true, takenOverFrom: [], diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index c93164f5c9..e72d0b80af 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -169,6 +169,7 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta issue: raw["issue"] as string | undefined, issueTitle: raw["issueTitle"] as string | undefined, pr: raw["pr"] as string | undefined, + prHistory: typeof raw["prHistory"] === "string" ? raw["prHistory"] : undefined, prAutoDetect: raw["prAutoDetect"] === "off" || raw["prAutoDetect"] === "false" || @@ -304,6 +305,7 @@ export function writeMetadata( if (metadata.issue) data["issue"] = metadata.issue; if (metadata.issueTitle) data["issueTitle"] = metadata.issueTitle; if (metadata.pr) data["pr"] = metadata.pr; + if (metadata.prHistory) data["prHistory"] = metadata.prHistory; if (metadata.prAutoDetect !== undefined) data["prAutoDetect"] = metadata.prAutoDetect; if (metadata.summary) data["summary"] = metadata.summary; if (metadata.project) data["project"] = metadata.project; diff --git a/packages/core/src/prompts/orchestrator.md b/packages/core/src/prompts/orchestrator.md index 2551bc2870..5ab658252d 100644 --- a/packages/core/src/prompts/orchestrator.md +++ b/packages/core/src/prompts/orchestrator.md @@ -67,14 +67,14 @@ ao open {{projectId}}{{REPO_CONFIGURED_SECTION_END}} ## Available Commands - `ao status`: Show all sessions{{REPO_CONFIGURED_SECTION_START}} with PR/CI/review status{{REPO_CONFIGURED_SECTION_END}} -- `ao spawn [issue] [--prompt ]{{REPO_CONFIGURED_SECTION_START}} [--claim-pr ]{{REPO_CONFIGURED_SECTION_END}}`: Spawn a worker session{{REPO_CONFIGURED_SECTION_START}}; use issue ID or --prompt for freeform tasks{{REPO_CONFIGURED_SECTION_END}}{{REPO_NOT_CONFIGURED_SECTION_START}} with --prompt for freeform tasks{{REPO_NOT_CONFIGURED_SECTION_END}} +- `ao spawn [issue] [--prompt ]{{REPO_CONFIGURED_SECTION_START}} [--claim-pr ] [--claim-pr-repo ]{{REPO_CONFIGURED_SECTION_END}}`: Spawn a worker session{{REPO_CONFIGURED_SECTION_START}}; use issue ID or --prompt for freeform tasks{{REPO_CONFIGURED_SECTION_END}}{{REPO_NOT_CONFIGURED_SECTION_START}} with --prompt for freeform tasks{{REPO_NOT_CONFIGURED_SECTION_END}} {{REPO_CONFIGURED_SECTION_START}}- `ao batch-spawn `: Spawn multiple sessions in parallel (project auto-detected) {{REPO_CONFIGURED_SECTION_END}}- `ao session ls [-p project]`: List all sessions (optionally filter by project) - `ao review list [project]`: List AO-local reviewer runs. These are review agents/runs, not coding worker sessions. - `ao review run [--execute]`: Request a reviewer run for a coding worker session. - `ao review execute [project] [--run ]`: Execute a queued reviewer run. - `ao review send [-p project]`: Send open AO-local findings from a completed reviewer run to its linked coding worker, then mark the run as waiting for worker updates. - {{REPO_CONFIGURED_SECTION_START}}- `ao session claim-pr [session]`: Attach an existing PR to a worker session + {{REPO_CONFIGURED_SECTION_START}}- `ao session claim-pr [session] [--repo ]`: Attach an existing PR to a worker session {{REPO_CONFIGURED_SECTION_END}}- `ao session attach `: Attach to a session's terminal (a tmux window on Unix; a ConPTY pty-host on Windows) - `ao session kill `: Kill a specific session - `ao session cleanup [-p project]`: Kill cleanup-eligible sessions (closed work or dead runtimes) diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 6eb6435944..294a1d00f7 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -69,6 +69,7 @@ import { parseCanonicalLifecycle, } from "./lifecycle-state.js"; import { buildPrompt } from "./prompt-builder.js"; +import { dedupePrUrls, parsePrFromUrl } from "./utils/pr.js"; import { classifyActivitySignal, createActivitySignal } from "./activity-signal.js"; import { getProjectSessionsDir, @@ -92,7 +93,6 @@ import { normalizeOrchestratorSessionStrategy, } from "./orchestrator-session-strategy.js"; import { sessionFromMetadata } from "./utils/session-from-metadata.js"; -import { dedupePrUrls } from "./utils/pr.js"; import { safeJsonParse, validateStatus } from "./utils/validation.js"; import { isGitBranchNameSafe } from "./utils.js"; import { resolveAgentSelection, resolveAgentSelectionForSession } from "./agent-selection.js"; @@ -105,12 +105,105 @@ import { const execFileAsync = promisify(execFile); const OPENCODE_DISCOVERY_TIMEOUT_MS = 10_000; const OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS = 10_000; +const MAX_PR_HISTORY_ENTRIES = 20; const INDEXED_PR_METADATA_KEY_REGEX = /^(prEnrichment|prReviewComments)_\d+$/; // On Windows, execFile cannot resolve .cmd shim extensions without invoking the shell. // windowsHide:true suppresses the conhost popup that the shell would otherwise flash. const EXEC_SHELL_OPTION = process.platform === "win32" ? ({ shell: true, windowsHide: true } as const) : ({} as const); +function normalizeRepoOverride(repoOverride: string | undefined): string | null { + const trimmed = repoOverride?.trim(); + if (!trimmed) return null; + + const segments = trimmed.split("/"); + const valid = + segments.length === 2 && + segments.every((segment) => { + return ( + segment.length > 0 && + segment !== "." && + segment !== ".." && + /^[A-Za-z0-9_.-]+$/.test(segment) + ); + }); + + if (!valid) { + throw new Error(`Invalid repo "${repoOverride}". Expected owner/repo.`); + } + + return segments.join("/"); +} + +function resolveClaimPRTarget( + reference: string, + project: ProjectConfig, + options: ClaimPROptions | undefined, +): { reference: string; project: ProjectConfig } { + const explicitRepo = normalizeRepoOverride(options?.repoOverride); + const parsed = parsePrFromUrl(reference); + const inferredRepo = parsed?.owner && parsed.repo ? `${parsed.owner}/${parsed.repo}` : null; + const repo = explicitRepo ?? inferredRepo; + + if (!repo) { + return { reference, project }; + } + + return { + reference: parsed?.number ? String(parsed.number) : reference, + project: { ...project, repo }, + }; +} + +function readPRHistory(raw: Record) { + const encoded = raw["prHistory"]; + if (!encoded) return []; + + try { + const parsed = JSON.parse(encoded) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.flatMap((entry) => { + if (typeof entry !== "object" || entry === null) return []; + const record = entry as Record; + if (typeof record["url"] !== "string" || !record["url"]) return []; + return [ + { + url: record["url"], + number: typeof record["number"] === "number" ? record["number"] : null, + branch: typeof record["branch"] === "string" ? record["branch"] : null, + replacedAt: typeof record["replacedAt"] === "string" ? record["replacedAt"] : "", + }, + ]; + }); + } catch { + return []; + } +} + +function buildPreviousPREntry(raw: Record, nextPrUrl: string, replacedAt: string) { + const previousUrl = raw["pr"]; + if (!previousUrl || previousUrl === nextPrUrl) return null; + + const lifecycle = parseCanonicalLifecycle(raw); + const parsed = parsePrFromUrl(previousUrl); + return { + url: previousUrl, + number: lifecycle.pr.number ?? parsed?.number ?? null, + branch: raw["branch"] || null, + replacedAt, + }; +} + +function appendPRHistory( + raw: Record, + previousPr: ReturnType, +) { + if (!previousPr) return null; + + const history = readPRHistory(raw).filter((entry) => entry.url !== previousPr.url); + history.push(previousPr); + return history.slice(-MAX_PR_HISTORY_ENTRIES); +} function errorIncludesSessionNotFound(err: unknown): boolean { if (!(err instanceof Error)) return false; @@ -310,7 +403,10 @@ async function isAgentProcessNotDefinitelyMissing( } function isFixedOrchestratorReservationError(err: unknown, sessionId: string): boolean { - return err instanceof Error && err.message.includes(`Orchestrator session "${sessionId}" already exists`); + return ( + err instanceof Error && + err.message.includes(`Orchestrator session "${sessionId}" already exists`) + ); } async function getTmuxForegroundCommand(sessionName: string): Promise { @@ -328,9 +424,7 @@ async function getTmuxForegroundCommand(sessionName: string): Promise, -): CanonicalSessionLifecycle | undefined { +function parseLifecycleFromRaw(raw: Record): CanonicalSessionLifecycle | undefined { const source = raw["lifecycle"] ?? raw["statePayload"]; if (!source) return undefined; try { @@ -673,7 +767,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const duplicatePRAttachments = new Map(); for (const record of repaired) { - if (!record.raw["lifecycle"] && (!record.raw["statePayload"] || record.raw["stateVersion"] !== "2")) { + if ( + !record.raw["lifecycle"] && + (!record.raw["statePayload"] || record.raw["stateVersion"] !== "2") + ) { const lifecycle = cloneLifecycle( parseCanonicalLifecycle(record.raw, { sessionId: record.sessionName, @@ -762,7 +859,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return repaired; } - function loadActiveSessionRecords(projectId: string, project: ProjectConfig): ActiveSessionRecord[] { + function loadActiveSessionRecords( + projectId: string, + project: ProjectConfig, + ): ActiveSessionRecord[] { const sessionsDir = getProjectSessionsDir(projectId); if (!existsSync(sessionsDir)) return []; @@ -918,9 +1018,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM ); for (let attempts = 0; attempts < 10_000; attempts++) { const sessionId = `${project.sessionPrefix}-${num}`; - const tmuxName = project.path - ? generateSessionName(project.sessionPrefix, num) - : undefined; + const tmuxName = project.path ? generateSessionName(project.sessionPrefix, num) : undefined; if (!usedNumbers.has(num) && reserveSessionId(sessionsDir, sessionId)) { return { num, sessionId, tmuxName }; @@ -2137,7 +2235,9 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return null; } - async function ensureOrchestratorInternal(orchestratorConfig: OrchestratorSpawnConfig): Promise { + async function ensureOrchestratorInternal( + orchestratorConfig: OrchestratorSpawnConfig, + ): Promise { const project = config.projects[orchestratorConfig.projectId]; if (!project) { throw new Error(`Unknown project: ${orchestratorConfig.projectId}`); @@ -2163,10 +2263,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy( project.orchestratorSessionStrategy, ); - if ( - orchestratorSessionStrategy === "delete" || - orchestratorSessionStrategy === "ignore" - ) { + if (orchestratorSessionStrategy === "delete" || orchestratorSessionStrategy === "ignore") { await kill(sessionId, { purgeOpenCode: orchestratorSessionStrategy === "delete" }); deleteMetadata(getProjectSessionsDir(orchestratorConfig.projectId), sessionId); return spawnOrchestrator(orchestratorConfig); @@ -2798,8 +2895,14 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM continue; } - const cleanupAgent = resolveSelectionForSession(project, terminatedId, terminatedRaw).agentName; - const mappedOpenCodeSessionId = asValidOpenCodeSessionId(terminatedRaw["opencodeSessionId"]); + const cleanupAgent = resolveSelectionForSession( + project, + terminatedId, + terminatedRaw, + ).agentName; + const mappedOpenCodeSessionId = asValidOpenCodeSessionId( + terminatedRaw["opencodeSessionId"], + ); if (cleanupAgent === "opencode" && terminatedRaw["opencodeCleanedAt"]) { pushSkipped(projectKey, terminatedId); continue; @@ -3201,12 +3304,22 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM ); } - const pr = await scm.resolvePR(reference, project); + const claimTarget = resolveClaimPRTarget(reference, project, options); + const pr = await scm.resolvePR(claimTarget.reference, claimTarget.project); const prState = await scm.getPRState(pr); if (prState !== PR_STATE.OPEN) { throw new Error(`Cannot claim PR #${pr.number} because it is ${prState}`); } + const claimRepoKey = (claimTarget.project.repo ?? "").trim().toLowerCase(); + const homeRepoKey = (project.repo ?? "").trim().toLowerCase(); + let sameRepoForBranchConflicts = true; + if (claimRepoKey && homeRepoKey) { + sameRepoForBranchConflicts = claimRepoKey === homeRepoKey; + } else if (claimRepoKey !== homeRepoKey) { + sameRepoForBranchConflicts = false; + } + const conflictingSessions = new Set(); const activeRecords = loadActiveSessionRecords(projectId, project).filter( (record) => record.sessionName !== sessionId, @@ -3226,7 +3339,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM ); const samePr = otherPrUrls.has(pr.url); const sameBranch = - otherRaw["branch"] === pr.branch && (otherRaw["prAutoDetect"] ?? "on") !== "off" && otherRaw["prAutoDetect"] !== "false"; + sameRepoForBranchConflicts && + otherRaw["branch"] === pr.branch && + (otherRaw["prAutoDetect"] ?? "on") !== "off" && + otherRaw["prAutoDetect"] !== "false"; if (samePr || sameBranch) { conflictingSessions.add(sessionName); @@ -3242,12 +3358,16 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const branchChanged = await scm.checkoutPR(pr, workspacePath); + const replacedAt = new Date().toISOString(); + const previousPr = buildPreviousPREntry(raw, pr.url, replacedAt); + const nextPRHistory = appendPRHistory(raw, previousPr); + const claimLifecycle = buildUpdatedLifecycle(sessionId, raw, (next) => { next.pr.state = "open"; next.pr.reason = "in_progress"; next.pr.number = pr.number; next.pr.url = pr.url; - next.pr.lastObservedAt = new Date().toISOString(); + next.pr.lastObservedAt = replacedAt; }); // Stack: push claimed PR to front — it becomes primary (prs[0]) on next load. // Filter out duplicates, keep all other tracked PRs at the back. @@ -3272,6 +3392,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM prs: newPrs, status: deriveLegacyStatus(claimLifecycle), branch: pr.branch, + ...(nextPRHistory ? { prHistory: JSON.stringify(nextPRHistory) } : {}), prAutoDetect: "", ...staleEnrichmentKeys, ...lifecycleMetadataUpdates(raw, claimLifecycle), @@ -3297,9 +3418,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM pr: "", prs: "", prAutoDetect: "false", - ...(PR_TRACKING_STATUSES.has(previousRaw["status"] ?? "") - ? { status: "working" } - : {}), + ...(PR_TRACKING_STATUSES.has(previousRaw["status"] ?? "") ? { status: "working" } : {}), ...lifecycleMetadataUpdates(previousRaw, previousLifecycle), }); invalidateCache(); @@ -3324,6 +3443,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM sessionId, projectId, pr, + previousPr, branchChanged, githubAssigned, githubAssignmentError, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 86850228e6..1188e6bcbb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -268,10 +268,7 @@ export function isRestorable(session: { lifecycle?: CanonicalSessionLifecycle; }): boolean { if (session.lifecycle) { - return ( - isTerminalSession(session) && - !NON_RESTORABLE_STATUSES.has(session.status) - ); + return isTerminalSession(session) && !NON_RESTORABLE_STATUSES.has(session.status); } return isTerminalSession(session) && !NON_RESTORABLE_STATUSES.has(session.status); } @@ -356,11 +353,7 @@ export function isOrchestratorSession( if (allSessionPrefixes) { for (const prefix of allSessionPrefixes) { if (prefix === sessionPrefix) continue; - if ( - new RegExp( - `^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\d+$`, - ).test(session.id) - ) { + if (new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\d+$`).test(session.id)) { return false; } } @@ -893,7 +886,11 @@ export interface SCM { * @param observer - Optional observer for batch operation metrics * @returns Map keyed by "${owner}/${repo}#${number}" containing enrichment data */ - enrichSessionsPRBatch?(prs: PRInfo[], observer?: BatchObserver, repos?: string[]): Promise>; + enrichSessionsPRBatch?( + prs: PRInfo[], + observer?: BatchObserver, + repos?: string[], + ): Promise>; /** * Optional: validate that this SCM's prerequisites (auth, CLI tools) are @@ -1361,7 +1358,7 @@ export interface ObservabilityConfig { /** Top-level orchestrator configuration (from agent-orchestrator.yaml) */ export interface OrchestratorConfig { /** Optional JSON Schema hint for editor autocomplete/validation. */ - "$schema"?: string; + $schema?: string; /** * Path to the config file (set automatically during load). @@ -1802,6 +1799,7 @@ export interface SessionMetadata { issue?: string; issueTitle?: string; // Issue title for event enrichment pr?: string; + prHistory?: string; // JSON-encoded PRHistoryEntry[] for replaced primary PRs prAutoDetect?: boolean; summary?: string; project?: string; @@ -1904,13 +1902,22 @@ export interface OpenCodeSessionManager extends SessionManager { export interface ClaimPROptions { assignOnGithub?: boolean; + repoOverride?: string; takeover?: boolean; } +export interface PRHistoryEntry { + url: string; + number: number | null; + branch: string | null; + replacedAt: string; +} + export interface ClaimPRResult { sessionId: SessionId; projectId: string; pr: PRInfo; + previousPr: PRHistoryEntry | null; branchChanged: boolean; githubAssigned: boolean; githubAssignmentError?: string; @@ -2055,40 +2062,44 @@ export class ProjectResolveError extends Error { /** A project entry in the portfolio index (merged from discovery + registration + preferences) */ export interface PortfolioProject { - id: string; // Stable portfolio identity (configProjectKey, with collision suffix if needed) - name: string; // Human-readable display name - configPath: string; // Absolute path to agent-orchestrator.yaml - configProjectKey: string; // Key in config.projects map - repoPath: string; // Absolute local filesystem path - repo?: string; // "owner/repo" for SCM + id: string; // Stable portfolio identity (configProjectKey, with collision suffix if needed) + name: string; // Human-readable display name + configPath: string; // Absolute path to agent-orchestrator.yaml + configProjectKey: string; // Key in config.projects map + repoPath: string; // Absolute local filesystem path + repo?: string; // "owner/repo" for SCM defaultBranch?: string; sessionPrefix: string; source: "discovered" | "registered" | "config"; // How this entry was found - enabled: boolean; // User can disable without removing - pinned: boolean; // User preference for ordering - lastSeenAt: string; // ISO timestamp - resolveError?: string; // Present only when the project is degraded + enabled: boolean; // User can disable without removing + pinned: boolean; // User preference for ordering + lastSeenAt: string; // ISO timestamp + resolveError?: string; // Present only when the project is degraded } /** User preferences overlay (canonical, small file) */ export interface PortfolioPreferences { version: 1; defaultProjectId?: string; - projectOrder?: string[]; // Ordered project IDs for display - projects?: Record; + projectOrder?: string[]; // Ordered project IDs for display + projects?: Record< + string, + { + // Per-project preferences + pinned?: boolean; + enabled?: boolean; + displayName?: string; + } + >; } /** Registered projects (explicit `ao project add`) */ export interface PortfolioRegistered { version: 1; projects: Array<{ - path: string; // Repo path - configProjectKey?: string; // Key in config if multi-project YAML - addedAt: string; // ISO timestamp + path: string; // Repo path + configProjectKey?: string; // Key in config if multi-project YAML + addedAt: string; // ISO timestamp }>; }