diff --git a/server/babysitter.test.ts b/server/babysitter.test.ts index 6daa80e..dba6f3e 100644 --- a/server/babysitter.test.ts +++ b/server/babysitter.test.ts @@ -6249,6 +6249,114 @@ test("runQueuedBabysitPR launches code-owner fallback after the default run fail assert.equal(jobs[0]?.payload.monitorReason, "GitHub mergeable state is blocked"); }); +test("runQueuedBabysitPR commits and pushes uncommitted code-owner fallback agent changes", async () => { + const storage = new MemStorage(); + await storage.updateConfig({ autoUpdateDocs: false }); + const pr = await storage.addPR({ + number: 106, + title: "Verbose PR", + repo: "alex-morgan-o/lolodex", + branch: "feature/verbose", + author: "octocat", + url: "https://github.com/alex-morgan-o/lolodex/pull/106", + status: "watching", + feedbackItems: [], + accepted: 0, + rejected: 0, + flagged: 0, + testsPassed: null, + lintPassed: null, + lastChecked: null, + }); + const worktreeRoot = await mkdtemp(path.join(os.tmpdir(), "codefactory-home-")); + process.env.CODEFACTORY_HOME = worktreeRoot; + const backgroundJobQueue = new BackgroundJobQueue(storage); + const gitCommands: string[] = []; + const applyCalls: Array<{ agent: string; cwd?: string }> = []; + + try { + const babysitter = new PRBabysitter( + storage, + makeWatcherGitHubService({ + fetchPullSummary: async () => makePullSummary(pr, { mergeableState: "blocked" }), + listFailingStatuses: async () => [{ + context: "build", + description: "TypeScript compilation failed", + targetUrl: "https://github.com/octo/example/actions/runs/1", + }], + }), + { + resolveAgent: async () => "claude", + ciPollIntervalMs: 0, + evaluateFixNecessityWithAgent: async () => ({ + needsFix: true, + reason: "Build failure needs a code change", + }), + applyFixesWithAgent: async ({ agent, cwd }) => { + applyCalls.push({ agent, cwd }); + if (applyCalls.length === 1) { + return { code: 1, stdout: "", stderr: "default run failed" }; + } + // Fallback agent modified files but did not commit or push them. + return { code: 0, stdout: "fallback handled the PR", stderr: "" }; + }, + runCommand: async (command: string, args: string[]) => { + gitCommands.push([command, ...args].join(" ")); + if (command !== "git") { + return { code: 1, stdout: "", stderr: `unexpected command: ${command}` }; + } + + // Worktree status reports uncommitted changes after the fallback agent. + if (args[0] === "status" && args[1] === "--porcelain") { + return { code: 0, stdout: " M src/file.ts\n", stderr: "" }; + } + + // Local head is a new commit the agent (or PatchDeck) created. + if (args[0] === "rev-parse" && args[1] === "HEAD") { + return { code: 0, stdout: "localnew123\n", stderr: "" }; + } + + // FETCH_HEAD reflects the remote after the push: once the fetch + // happened, the remote head matches the locally created commit. + if (args[0] === "-C" && args[2] === "rev-parse" && args[3] === "FETCH_HEAD") { + const alreadyPushed = gitCommands.some((cmd) => cmd.startsWith("git push") && cmd.includes("HEAD:feature/verbose")); + return { code: 0, stdout: `${alreadyPushed ? "localnew123" : "remoteold456"}\n`, stderr: "" }; + } + + if (args[0] === "-C" && args[2] === "status") { + return { code: 0, stdout: "", stderr: "" }; + } + + if (args[0] === "-C" && args[2] === "fetch") { + return { code: 0, stdout: "fetched\n", stderr: "" }; + } + + return { code: 0, stdout: "", stderr: "" }; + }, + }, + undefined, + async (...args) => backgroundJobQueue.enqueue(...args), + ); + + await babysitter.runQueuedBabysitPR(pr.id, "claude"); + } finally { + delete process.env.CODEFACTORY_HOME; + } + + const [run] = await storage.listAgentRuns({ prId: pr.id }); + const logs = await storage.getLogs(pr.id); + + assert.equal(applyCalls.length, 2); + assert.equal(run?.status, "completed"); + assert.equal(run?.phase, "code-owner-fallback.completed"); + + // PatchDeck should have staged, committed, and pushed the uncommitted agent edits. + assert.ok(gitCommands.some((cmd) => cmd.startsWith("git add -A")), "expected git add -A"); + assert.ok(gitCommands.some((cmd) => cmd.includes("commit") && cmd.includes("--no-verify")), "expected git commit"); + assert.ok(gitCommands.some((cmd) => cmd.startsWith("git push") && cmd.includes("HEAD:feature/verbose")), "expected git push to PR branch"); + assert.ok(logs.some((log) => log.phase === "code-owner-fallback" && log.message.includes("finalized")), "expected finalize log"); +}); + test("runQueuedBabysitPR records code-owner fallback failure phase", async () => { const storage = new MemStorage(); await storage.updateConfig({ autoUpdateDocs: false }); diff --git a/server/babysitter.ts b/server/babysitter.ts index b28bdc8..4fc7005 100644 --- a/server/babysitter.ts +++ b/server/babysitter.ts @@ -3340,6 +3340,92 @@ export class PRBabysitter { return true; }; + const finalizeCodeOwnerFallbackWorktree = async (params: { + prId: string; + cwd: string; + repoCacheDir: string; + remoteName: string; + headRef: string; + agent: CodingAgent; + }): Promise => { + const { prId, cwd, repoCacheDir, remoteName, headRef, agent } = params; + const phase = "code-owner-fallback"; + + // Commit any uncommitted agent edits so the local head captures them. + const committed = await commitDirtyWorktree({ + currentPrId: prId, + cwd, + commitArgs: ["commit", "--no-verify", "--no-edit", "-m", `Apply ${agent} code-owner fallback fixes for PR`], + phase, + context: "code-owner fallback agent run", + }); + + // If the agent already committed but did not push (or we just committed), + // push the local head to the PR branch. When the agent already pushed, + // local HEAD equals the remote HEAD and the push is a no-op success. + const pushResult = await runLoggedCommand({ + currentPrId: prId, + command: "git", + args: ["push", remoteName, `HEAD:${headRef}`], + cwd, + timeoutMs: 120000, + phase, + successMessage: `Pushed code-owner fallback work to ${remoteName}/${headRef}`, + }); + if (pushResult.code !== 0) { + throw new Error(formatGitFailure(`pushing ${remoteName}/${headRef} after code-owner fallback`, pushResult)); + } + + // Confirm the pushed head is visible on the remote. + const remoteFetch = await runLoggedCommand({ + currentPrId: prId, + command: "git", + args: ["-C", repoCacheDir, "fetch", remoteName, headRef], + timeoutMs: 120000, + phase, + successMessage: `Fetched ${remoteName}/${headRef} after code-owner fallback push`, + }); + if (remoteFetch.code !== 0) { + throw new Error(formatGitFailure(`fetching ${remoteName}/${headRef} after code-owner fallback push`, remoteFetch)); + } + + const remoteHead = await runLoggedCommand({ + currentPrId: prId, + command: "git", + args: ["-C", repoCacheDir, "rev-parse", "FETCH_HEAD"], + timeoutMs: 5000, + phase, + successMessage: "Collected remote PR head SHA after code-owner fallback push", + }); + if (remoteHead.code !== 0) { + throw new Error(formatGitFailure("reading remote PR head after code-owner fallback push", remoteHead)); + } + + const localHead = await runLoggedCommand({ + currentPrId: prId, + command: "git", + args: ["rev-parse", "HEAD"], + cwd, + timeoutMs: 5000, + phase, + successMessage: "Collected local PR head SHA after code-owner fallback", + }); + if (localHead.code !== 0) { + throw new Error(formatGitFailure("reading local PR head after code-owner fallback", localHead)); + } + + if (localHead.stdout.trim() !== remoteHead.stdout.trim()) { + throw new Error( + `Code-owner fallback work was not reflected on ${remoteName}/${headRef} (local ${localHead.stdout.trim().slice(0, 8)}, remote ${remoteHead.stdout.trim().slice(0, 8)})`, + ); + } + + await queueLog(prId, "info", `Code-owner fallback work finalized on ${remoteName}/${headRef}`, { + phase, + metadata: { committed, remoteHead: remoteHead.stdout.trim().slice(0, 12) }, + }); + }; + const runCodeOwnerFallbackAfterFailure = async (params: { pr: PR; failureMessage: string; @@ -3482,6 +3568,20 @@ export class PRBabysitter { metadata: { agent, cwd }, }); + // The code-owner fallback agent is asked to commit and push its own + // changes, but agents sometimes finish without doing so (e.g. a + // truncated model response). If the worktree has uncommitted changes + // or a local commit that was not pushed, PatchDeck finalizes the PR + // branch so the review threads can be closed against the real head. + await finalizeCodeOwnerFallbackWorktree({ + prId: pr.id, + cwd: worktreePath, + repoCacheDir, + remoteName, + headRef: pullSummary.headRef, + agent, + }); + return { agent, prompt }; } finally { await stdoutLogger.flush();