From e13a383c6b1cbeea19159ca03d757e44a5d567df Mon Sep 17 00:00:00 2001 From: ChiragArora31 Date: Sun, 10 May 2026 20:17:24 +0530 Subject: [PATCH 1/4] feat(core): support PR handoff history --- docs/CLI.md | 8 + .../cli/__tests__/commands/session.test.ts | 54 ++-- packages/cli/__tests__/commands/spawn.test.ts | 115 +++++-- packages/cli/src/commands/session.ts | 297 +++++++++--------- packages/cli/src/commands/spawn.ts | 32 +- .../session-manager/claim-pr.test.ts | 88 +++++- packages/core/src/__tests__/test-utils.ts | 1 + packages/core/src/metadata.ts | 168 ++++++---- packages/core/src/prompts/orchestrator.md | 6 +- packages/core/src/session-manager.ts | 162 ++++++++-- packages/core/src/types.ts | 71 +++-- 11 files changed, 668 insertions(+), 334 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index ab4bae5c73..5e448c7c0d 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -22,15 +22,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 60ffeb6c7a..fc9e659c1c 100644 --- a/packages/cli/__tests__/commands/spawn.test.ts +++ b/packages/cli/__tests__/commands/spawn.test.ts @@ -4,23 +4,21 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { type Session, type SessionManager, getProjectBaseDir } from "@aoagents/ao-core"; -const { mockExec, mockConfigRef, mockSessionManager, mockGetRunning } = vi.hoisted( - () => ({ - mockExec: vi.fn(), - mockConfigRef: { current: null as Record | null }, - mockSessionManager: { - list: vi.fn(), - kill: vi.fn(), - cleanup: vi.fn(), - get: vi.fn(), - spawn: vi.fn(), - spawnOrchestrator: vi.fn(), - send: vi.fn(), - claimPR: vi.fn(), - }, - mockGetRunning: vi.fn(), - }), -); +const { mockExec, mockConfigRef, mockSessionManager, mockGetRunning } = vi.hoisted(() => ({ + mockExec: vi.fn(), + mockConfigRef: { current: null as Record | null }, + mockSessionManager: { + list: vi.fn(), + kill: vi.fn(), + cleanup: vi.fn(), + get: vi.fn(), + spawn: vi.fn(), + spawnOrchestrator: vi.fn(), + send: vi.fn(), + claimPR: vi.fn(), + }, + mockGetRunning: vi.fn(), +})); vi.mock("../../src/lib/shell.js", () => ({ tmux: vi.fn(), @@ -534,9 +532,7 @@ describe("spawn command", () => { it("reports error when spawn fails", async () => { mockSessionManager.spawn.mockRejectedValue(new Error("worktree creation failed")); - await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow( - "process.exit(1)", - ); + await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow("process.exit(1)"); }); it("claims a PR for the spawned session when --claim-pr is provided", async () => { @@ -570,6 +566,7 @@ describe("spawn command", () => { baseBranch: "main", isDraft: false, }, + previousPr: null, branchChanged: true, githubAssigned: false, takenOverFrom: [], @@ -584,6 +581,7 @@ describe("spawn command", () => { }); expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-1", "123", { assignOnGithub: undefined, + repoOverride: undefined, }); const succeedMsg = String(mockSpinner.succeed.mock.calls[0]?.[0] ?? ""); @@ -623,25 +621,85 @@ 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", - "--assign-on-github", + "--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"]), @@ -791,6 +849,7 @@ describe("spawn pre-flight checks", () => { baseBranch: "main", isDraft: false, }, + previousPr: null, branchChanged: true, githubAssigned: false, takenOverFrom: [], @@ -860,7 +919,9 @@ describe("batch-spawn command", () => { return cmd; } - function makeFakeSession(overrides: Partial & Pick): Session { + function makeFakeSession( + overrides: Partial & Pick, + ): Session { return { status: "spawning", activity: null, @@ -1001,9 +1062,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) @@ -1022,9 +1081,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 fed2f05858..d10960028f 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -96,6 +96,7 @@ function resolveProjectAndIssue( interface SpawnClaimOptions { claimPr?: string; + claimPrRepo?: string; assignOnGithub?: boolean; } @@ -230,6 +231,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) { @@ -243,9 +245,7 @@ async function spawnSession( 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}`, - ); + 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 @@ -278,8 +278,12 @@ 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 ", "Initial prompt/instructions for the agent (use instead of an issue)") + .option( + "--prompt ", + "Initial prompt/instructions for the agent (use instead of an issue)", + ) .action( async ( issue: string | undefined, @@ -287,6 +291,7 @@ export function registerSpawn(program: Command): void { open?: boolean; agent?: string; claimPr?: string; + claimPrRepo?: string; assignOnGithub?: boolean; prompt?: string; }, @@ -317,9 +322,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, }; @@ -327,7 +337,15 @@ export function registerSpawn(program: Command): void { await runSpawnPreflight(config, projectId, claimOptions); await ensureAOPollingProject(projectId); - await spawnSession(config, projectId, issueId, opts.open, opts.agent, claimOptions, opts.prompt); + 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); @@ -386,9 +404,7 @@ export function registerBatchSpawn(program: Command): void { 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(` ${chalk.bold(pid)}: ${items.map((i) => i.original).join(", ")}`); } console.log(); 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 3625a0c1e9..84e6464e41 100644 --- a/packages/core/src/__tests__/session-manager/claim-pr.test.ts +++ b/packages/core/src/__tests__/session-manager/claim-pr.test.ts @@ -1,13 +1,8 @@ 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 { - writeMetadata, - readMetadataRaw, -} from "../../metadata.js"; +import { writeMetadata, readMetadataRaw } from "../../metadata.js"; import { type OrchestratorConfig, type PluginRegistry, @@ -16,7 +11,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; @@ -108,6 +108,64 @@ 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("consolidates ownership by disabling PR auto-detect on the previous session", async () => { const mockSCM = makeSCM(); @@ -384,6 +442,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 e7a51e9dff..20231611af 100644 --- a/packages/core/src/__tests__/test-utils.ts +++ b/packages/core/src/__tests__/test-utils.ts @@ -512,6 +512,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 a4f3a19c5d..9a9be275b6 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -23,7 +23,12 @@ import { constants, } from "node:fs"; import { join, dirname } from "node:path"; -import type { CanonicalSessionLifecycle, RuntimeHandle, SessionId, SessionMetadata } from "./types.js"; +import type { + CanonicalSessionLifecycle, + RuntimeHandle, + SessionId, + SessionMetadata, +} from "./types.js"; import { atomicWriteFileSync } from "./atomic-write.js"; import { buildLifecycleMetadataPatch, @@ -94,7 +99,9 @@ function parseRuntimeHandleField(value: unknown): RuntimeHandle | undefined { if (typeof parsed["id"] === "string" && typeof parsed["runtimeName"] === "string") { return parsed as unknown as RuntimeHandle; } - } catch { /* not valid JSON */ } + } catch { + /* not valid JSON */ + } } return undefined; } @@ -106,13 +113,16 @@ function parseDashboardField(raw: Record): SessionMetadata["das return { port: typeof d["port"] === "number" ? d["port"] : undefined, terminalWsPort: typeof d["terminalWsPort"] === "number" ? d["terminalWsPort"] : undefined, - directTerminalWsPort: typeof d["directTerminalWsPort"] === "number" ? d["directTerminalWsPort"] : undefined, + directTerminalWsPort: + typeof d["directTerminalWsPort"] === "number" ? d["directTerminalWsPort"] : undefined, }; } // Legacy format: flat fields const port = typeof raw["dashboardPort"] === "number" ? raw["dashboardPort"] : undefined; - const terminalWsPort = typeof raw["terminalWsPort"] === "number" ? raw["terminalWsPort"] : undefined; - const directTerminalWsPort = typeof raw["directTerminalWsPort"] === "number" ? raw["directTerminalWsPort"] : undefined; + const terminalWsPort = + typeof raw["terminalWsPort"] === "number" ? raw["terminalWsPort"] : undefined; + const directTerminalWsPort = + typeof raw["directTerminalWsPort"] === "number" ? raw["directTerminalWsPort"] : undefined; if (port !== undefined || terminalWsPort !== undefined || directTerminalWsPort !== undefined) { return { port, terminalWsPort, directTerminalWsPort }; } @@ -159,8 +169,15 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta issueTitle: raw["issueTitle"] as string | undefined, pr: raw["pr"] as string | undefined, prAutoDetect: - raw["prAutoDetect"] === "off" || raw["prAutoDetect"] === "false" || raw["prAutoDetect"] === false ? false : - raw["prAutoDetect"] === "on" || raw["prAutoDetect"] === "true" || raw["prAutoDetect"] === true ? true : undefined, + raw["prAutoDetect"] === "off" || + raw["prAutoDetect"] === "false" || + raw["prAutoDetect"] === false + ? false + : raw["prAutoDetect"] === "on" || + raw["prAutoDetect"] === "true" || + raw["prAutoDetect"] === true + ? true + : undefined, summary: raw["summary"] as string | undefined, project: raw["project"] as string | undefined, agent: raw["agent"] as string | undefined, @@ -220,8 +237,12 @@ export function readMetadataRaw( /** Fields that are stored as JSON objects and should be parsed when unflattening. */ const jsonFields = new Set([ - "runtimeHandle", "lifecycle", "statePayload", "dashboard", - "agentReport", "reportWatcher", + "runtimeHandle", + "lifecycle", + "statePayload", + "dashboard", + "agentReport", + "reportWatcher", ]); /** Unflatten a Record to proper types for JSON storage. */ @@ -233,7 +254,12 @@ function unflattenFromStringRecord(data: Record): Record>, ): void { - mutateMetadata(dataDir, sessionId, (existing) => { - return applyMetadataUpdates(existing, updates); - }, { createIfMissing: true }); + mutateMetadata( + dataDir, + sessionId, + (existing) => { + return applyMetadataUpdates(existing, updates); + }, + { createIfMissing: true }, + ); } export function applyMetadataUpdates( @@ -345,50 +377,54 @@ export function mutateMetadata( const path = metadataPath(dataDir, sessionId); const lockPath = `${path}.lock`; - return withFileLockSync(lockPath, () => { - let existing: Record = {}; + return withFileLockSync( + lockPath, + () => { + let existing: Record = {}; - let content: string | undefined; - try { - content = readFileSync(path, "utf-8").trim(); - } catch { - // File doesn't exist - } + let content: string | undefined; + try { + content = readFileSync(path, "utf-8").trim(); + } catch { + // File doesn't exist + } - if (content !== undefined) { - if (content) { - const raw = parseMetadataContent(content); - if (raw) { - existing = flattenToStringRecord(raw); - } else { - // Corrupt JSON. Preserve forensic evidence by side-renaming - // the file before we overwrite it with the merged update. - // Without this, the very next mutateMetadata call destroys - // the corrupt bytes permanently and the user has no signal - // that anything was wrong — the file just becomes "not - // corrupt anymore — and missing fields". - const corruptPath = `${path}.corrupt-${Date.now()}`; - try { - renameSync(path, corruptPath); - // eslint-disable-next-line no-console - console.warn( - `[metadata] corrupt JSON at ${path}; preserved as ${corruptPath} before rewriting`, - ); - } catch { - // best effort — proceed even if the rename fails (e.g. EACCES) + if (content !== undefined) { + if (content) { + const raw = parseMetadataContent(content); + if (raw) { + existing = flattenToStringRecord(raw); + } else { + // Corrupt JSON. Preserve forensic evidence by side-renaming + // the file before we overwrite it with the merged update. + // Without this, the very next mutateMetadata call destroys + // the corrupt bytes permanently and the user has no signal + // that anything was wrong — the file just becomes "not + // corrupt anymore — and missing fields". + const corruptPath = `${path}.corrupt-${Date.now()}`; + try { + renameSync(path, corruptPath); + // eslint-disable-next-line no-console + console.warn( + `[metadata] corrupt JSON at ${path}; preserved as ${corruptPath} before rewriting`, + ); + } catch { + // best effort — proceed even if the rename fails (e.g. EACCES) + } } } + } else if (!options.createIfMissing) { + return null; } - } else if (!options.createIfMissing) { - return null; - } - const next = normalizeMetadataRecord(updater({ ...existing })); + const next = normalizeMetadataRecord(updater({ ...existing })); - mkdirSync(dirname(path), { recursive: true }); - atomicWriteFileSync(path, serializeMetadata(unflattenFromStringRecord(next))); - return next; - }, { timeoutMs: 5_000, staleMs: 30_000 }); + mkdirSync(dirname(path), { recursive: true }); + atomicWriteFileSync(path, serializeMetadata(unflattenFromStringRecord(next))); + return next; + }, + { timeoutMs: 5_000, staleMs: 30_000 }, + ); } export function readCanonicalLifecycle( @@ -405,11 +441,7 @@ export function writeCanonicalLifecycle( sessionId: SessionId, lifecycle: CanonicalSessionLifecycle, ): void { - updateMetadata( - dataDir, - sessionId, - buildLifecycleMetadataPatch(cloneLifecycle(lifecycle)), - ); + updateMetadata(dataDir, sessionId, buildLifecycleMetadataPatch(cloneLifecycle(lifecycle))); } export function updateCanonicalLifecycle( @@ -450,18 +482,20 @@ export function listMetadata(dataDir: string): SessionId[] { const dir = dataDir; if (!existsSync(dir)) return []; - return readdirSync(dir).filter((name) => { - // Must be a .json file - if (!name.endsWith(JSON_EXTENSION)) return false; - const baseName = name.slice(0, -JSON_EXTENSION.length); - if (!baseName || baseName.startsWith(".")) return false; - if (!SESSION_ID_COMPONENT_PATTERN.test(baseName)) return false; - try { - return statSync(join(dir, name)).isFile(); - } catch { - return false; - } - }).map((name) => name.slice(0, -JSON_EXTENSION.length)); + return readdirSync(dir) + .filter((name) => { + // Must be a .json file + if (!name.endsWith(JSON_EXTENSION)) return false; + const baseName = name.slice(0, -JSON_EXTENSION.length); + if (!baseName || baseName.startsWith(".")) return false; + if (!SESSION_ID_COMPONENT_PATTERN.test(baseName)) return false; + try { + return statSync(join(dir, name)).isFile(); + } catch { + return false; + } + }) + .map((name) => name.slice(0, -JSON_EXTENSION.length)); } /** diff --git a/packages/core/src/prompts/orchestrator.md b/packages/core/src/prompts/orchestrator.md index 08ca7ec023..de1b1c5997 100644 --- a/packages/core/src/prompts/orchestrator.md +++ b/packages/core/src/prompts/orchestrator.md @@ -56,15 +56,15 @@ ao open {{projectId}}{{REPO_CONFIGURED_SECTION_END}} > **Note:** No repository remote is configured. Issue tracking, PR, and CI features are unavailable. > Add a `repo` field (owner/repo) to `agent-orchestrator.yaml` to enable them. -{{REPO_NOT_CONFIGURED_SECTION_END}} +> {{REPO_NOT_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) - {{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 ec03dbb57e..2787abc3f4 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -68,6 +68,7 @@ import { parseCanonicalLifecycle, } from "./lifecycle-state.js"; import { buildPrompt } from "./prompt-builder.js"; +import { parsePrFromUrl } from "./utils/pr.js"; import { classifyActivitySignal, createActivitySignal } from "./activity-signal.js"; import { getProjectSessionsDir, @@ -103,11 +104,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; // 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; const e = err as Error & { stderr?: string; stdout?: string }; @@ -292,7 +387,10 @@ function sleep(ms: number): Promise { } 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 { @@ -310,9 +408,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 { @@ -442,11 +538,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // previous launch ran far enough to write to ~/.kimi/sessions. A failed // launch (e.g. the --agent-file YAML crash) leaves no session to resume, // and falling back to a fresh getLaunchCommand is the only sensible choice. - return ( - agentName === "claude-code" || - agentName === "codex" || - agentName === "opencode" - ); + return agentName === "claude-code" || agentName === "codex" || agentName === "opencode"; } function applyMetadataUpdatesToRaw( @@ -593,7 +685,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, @@ -675,7 +770,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 []; @@ -831,9 +929,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 }; @@ -1783,7 +1879,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}`); @@ -1795,10 +1893,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); @@ -2259,8 +2354,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; @@ -2614,7 +2715,8 @@ 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}`); @@ -2631,7 +2733,9 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const samePr = otherRaw["pr"] === pr.url; const sameBranch = - otherRaw["branch"] === pr.branch && (otherRaw["prAutoDetect"] ?? "on") !== "off" && otherRaw["prAutoDetect"] !== "false"; + otherRaw["branch"] === pr.branch && + (otherRaw["prAutoDetect"] ?? "on") !== "off" && + otherRaw["prAutoDetect"] !== "false"; if (samePr || sameBranch) { conflictingSessions.add(sessionName); @@ -2647,17 +2751,22 @@ 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; }); updateMetadata(sessionsDir, sessionId, { pr: pr.url, status: deriveLegacyStatus(claimLifecycle), branch: pr.branch, + ...(nextPRHistory ? { prHistory: JSON.stringify(nextPRHistory) } : {}), prAutoDetect: "", ...lifecycleMetadataUpdates(raw, claimLifecycle), }); @@ -2681,9 +2790,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM updateMetadata(sessionsDir, previousSessionId, { pr: "", prAutoDetect: "false", - ...(PR_TRACKING_STATUSES.has(previousRaw["status"] ?? "") - ? { status: "working" } - : {}), + ...(PR_TRACKING_STATUSES.has(previousRaw["status"] ?? "") ? { status: "working" } : {}), ...lifecycleMetadataUpdates(previousRaw, previousLifecycle), }); invalidateCache(); @@ -2708,6 +2815,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 7d10d10c4a..607a79e9b9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -263,10 +263,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); } @@ -346,11 +343,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; } } @@ -856,7 +849,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 @@ -1308,7 +1305,7 @@ export interface LifecycleConfig { /** 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). @@ -1743,6 +1740,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; @@ -1838,13 +1836,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; @@ -1986,40 +1993,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 }>; } From c5c835449639ed47d647cb696ac455db55770991 Mon Sep 17 00:00:00 2001 From: ChiragArora31 Date: Mon, 11 May 2026 21:51:47 +0530 Subject: [PATCH 2/4] fix(core): address Greptile PR handoff review - Map prHistory in readMetadata to match writeMetadata and SessionMetadata - Remove stray blockquote prefix before REPO_NOT_CONFIGURED template end - Scope same-branch claim conflicts to the same SCM repo as the claimed PR - Add regression tests for prHistory read and cross-repo branch collisions Co-authored-by: Cursor --- packages/core/src/__tests__/metadata.test.ts | 15 ++++++ .../session-manager/claim-pr.test.ts | 52 +++++++++++++++++++ packages/core/src/metadata.ts | 1 + packages/core/src/prompts/orchestrator.md | 2 +- packages/core/src/session-manager.ts | 10 ++++ 5 files changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/core/src/__tests__/metadata.test.ts b/packages/core/src/__tests__/metadata.test.ts index 03ac1c16bf..3c17cd2584 100644 --- a/packages/core/src/__tests__/metadata.test.ts +++ b/packages/core/src/__tests__/metadata.test.ts @@ -73,6 +73,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 84e6464e41..d79f1d719e 100644 --- a/packages/core/src/__tests__/session-manager/claim-pr.test.ts +++ b/packages/core/src/__tests__/session-manager/claim-pr.test.ts @@ -166,6 +166,58 @@ describe("claimPR", () => { 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(); diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 9a9be275b6..cec895d235 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -168,6 +168,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" || diff --git a/packages/core/src/prompts/orchestrator.md b/packages/core/src/prompts/orchestrator.md index de1b1c5997..439cf94bf8 100644 --- a/packages/core/src/prompts/orchestrator.md +++ b/packages/core/src/prompts/orchestrator.md @@ -56,7 +56,7 @@ ao open {{projectId}}{{REPO_CONFIGURED_SECTION_END}} > **Note:** No repository remote is configured. Issue tracking, PR, and CI features are unavailable. > Add a `repo` field (owner/repo) to `agent-orchestrator.yaml` to enable them. -> {{REPO_NOT_CONFIGURED_SECTION_END}} +{{REPO_NOT_CONFIGURED_SECTION_END}} ## Available Commands diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 8dad69cb05..ec9b25a699 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -2730,6 +2730,15 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM 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, @@ -2741,6 +2750,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const samePr = otherRaw["pr"] === pr.url; const sameBranch = + sameRepoForBranchConflicts && otherRaw["branch"] === pr.branch && (otherRaw["prAutoDetect"] ?? "on") !== "off" && otherRaw["prAutoDetect"] !== "false"; From fec109f858500d8660ea532671e0330a86981e40 Mon Sep 17 00:00:00 2001 From: ChiragArora31 Date: Sun, 17 May 2026 05:14:45 +0530 Subject: [PATCH 3/4] fix(core): require owner repo override format Co-authored-by: Cursor --- .../__tests__/session-manager/claim-pr.test.ts | 18 ++++++++++++++++++ packages/core/src/session-manager.ts | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) 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 d79f1d719e..eb7833717c 100644 --- a/packages/core/src/__tests__/session-manager/claim-pr.test.ts +++ b/packages/core/src/__tests__/session-manager/claim-pr.test.ts @@ -166,6 +166,24 @@ describe("claimPR", () => { 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 }) => { diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 1d3cffde53..316c6cdd8a 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -117,7 +117,7 @@ function normalizeRepoOverride(repoOverride: string | undefined): string | null const segments = trimmed.split("/"); const valid = - segments.length >= 2 && + segments.length === 2 && segments.every((segment) => { return ( segment.length > 0 && From 05d2f01057cca76e724c96d29f861852abdc6bb9 Mon Sep 17 00:00:00 2001 From: ChiragArora31 Date: Mon, 8 Jun 2026 07:29:29 +0530 Subject: [PATCH 4/4] fix(core): dedupe PR utility imports --- packages/core/src/session-manager.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 404e998386..294a1d00f7 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -69,7 +69,7 @@ import { parseCanonicalLifecycle, } from "./lifecycle-state.js"; import { buildPrompt } from "./prompt-builder.js"; -import { parsePrFromUrl } from "./utils/pr.js"; +import { dedupePrUrls, parsePrFromUrl } from "./utils/pr.js"; import { classifyActivitySignal, createActivitySignal } from "./activity-signal.js"; import { getProjectSessionsDir, @@ -93,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";