Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions server/babysitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
100 changes: 100 additions & 0 deletions server/babysitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
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;
Expand Down Expand Up @@ -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();
Expand Down