From 889c02f12fe94d56332ec4890c41cb4aabdd1433 Mon Sep 17 00:00:00 2001 From: cwbcheng Date: Thu, 6 Aug 2026 01:54:21 +0800 Subject: [PATCH 1/6] fix: reply to and resolve rejected review threads Rejected review-thread feedback previously fell through the GitHub follow-up path silently: needsGitHubFollowUp only considered accepted items, so a rejected review comment was never replied to and its conversation was never resolved, contradicting the agent prompt that says to reply with the rejection reason and resolve the conversation. Now rejected review threads (excluding PatchDeck's own status/audit-trail replies) are collected as follow-up tasks, get a 'Rejected - no code change made' reply with the reason, and the conversation is resolved. Audit-trail verification skips the code-change audit for these items and only requires thread resolution, and the final status update keeps the rejection decision while advancing the thread-resolved flag. --- package.json | 5 ++ server/babysitter.test.ts | 114 +++++++++++++++++++++++++++++++++++++- server/babysitter.ts | 63 ++++++++++++++++----- 3 files changed, 166 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index ba19b6b..8d3e1d0 100644 --- a/package.json +++ b/package.json @@ -163,5 +163,10 @@ "drizzle-kit": { "@esbuild-kit/esm-loader": "npm:tsx@^4.20.4" } + }, + "allowScripts": { + "esbuild@0.28.0": true, + "esbuild@0.27.4": true, + "esbuild@0.25.12": true } } diff --git a/server/babysitter.test.ts b/server/babysitter.test.ts index 6daa80e..09658d5 100644 --- a/server/babysitter.test.ts +++ b/server/babysitter.test.ts @@ -4601,16 +4601,112 @@ test("babysitPR does not pull rejected or resolved items into in_progress", asyn const updated = await storage.getPR(pr.id); const updatedRejected = updated?.feedbackItems.find((i) => i.id === rejectedItem.id); const updatedResolved = updated?.feedbackItems.find((i) => i.id === resolvedItem.id); - assert.equal(updatedRejected?.status, "rejected", "rejected item should keep its status"); + // Rejected items are no longer pulled into in_progress (no code fix), but a + // rejected review thread now gets a closing GitHub follow-up + resolution. + assert.equal(updatedRejected?.decision, "reject", "rejected item should keep its decision"); assert.equal(updatedResolved?.status, "resolved", "resolved item should keep its status"); delete process.env.CODEFACTORY_HOME; }); +test("babysitPR replies to and resolves rejected review threads", async () => { + const storage = new MemStorage(); + await storage.updateConfig({ autoUpdateDocs: false }); + const rejectedItem = makeFeedbackItem({ + id: "gh-review-comment-rejected-thread", + status: "rejected", + decision: "reject", + decisionReason: "The referenced code does not exist in this branch.", + statusReason: "The referenced code does not exist in this branch.", + }); + 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: [rejectedItem], + accepted: 0, + rejected: 1, + flagged: 0, + testsPassed: null, + lintPassed: null, + lastChecked: null, + }); + + const worktreeRoot = await mkdtemp(path.join(os.tmpdir(), "codefactory-home-")); + process.env.CODEFACTORY_HOME = worktreeRoot; + let feedbackFetchCount = 0; + const pullSummary = makePullSummary(pr); + const postedFollowUps: Array<{ id: string; body: string; resolve?: boolean }> = []; + const resolvedThreads: string[] = []; + + const babysitter = new PRBabysitter( + storage, + { + buildOctokit: async () => ({}) as never, + fetchFeedbackItemsForPR: async () => { + feedbackFetchCount += 1; + if (feedbackFetchCount === 1) return [rejectedItem]; + return [{ ...rejectedItem, threadResolved: true }]; + }, + fetchPullSummary: async () => pullSummary, + listFailingStatuses: async () => [], + checkCISettled: async () => true, + listOpenPullsForRepo: async () => [], + postFollowUpForFeedbackItem: async (_octokit, _parsed, item, body, options) => { + postedFollowUps.push({ id: item.id, body, resolve: options?.resolve }); + if (options?.resolve && item.threadId) { + resolvedThreads.push(item.threadId); + } + }, + resolveReviewThread: async (_octokit, _parsed, threadId) => { + resolvedThreads.push(threadId); + }, + resolveGitHubAuthToken: async () => "test-token", + addReactionToComment: async () => {}, + postStatusReplyForFeedbackItem: async () => null, + updateStatusReply: async () => {}, + }, + { + resolveAgent: async () => "codex", + ciPollIntervalMs: 0, + evaluateFixNecessityWithAgent: async () => { + throw new Error("evaluateFixNecessityWithAgent should not be called for already-rejected items"); + }, + applyFixesWithAgent: async () => ({ code: 0, stdout: "", stderr: "" }), + runCommand: makeGitRunCommand(), + }, + ); + + await babysitter.babysitPR(pr.id, "codex"); + + const updated = await storage.getPR(pr.id); + const updatedItem = updated?.feedbackItems.find((i) => i.id === rejectedItem.id); + + assert.equal(postedFollowUps.length, 1, "rejected review thread should get a GitHub follow-up"); + assert.equal(postedFollowUps[0]?.id, rejectedItem.id); + assert.equal(postedFollowUps[0]?.resolve, true, "follow-up should resolve the conversation"); + assert.match(postedFollowUps[0]?.body ?? "", /Rejected/, "follow-up body should explain the rejection"); + assert.equal(resolvedThreads.includes(rejectedItem.threadId ?? ""), true, "thread should be resolved on GitHub"); + assert.equal(updatedItem?.threadResolved, true, "feedback item should be marked thread-resolved"); + + delete process.env.CODEFACTORY_HOME; +}); + test("babysitPR skips run when no items are pending or queued", async () => { const storage = new MemStorage(); await storage.updateConfig({ autoUpdateDocs: false }); - const rejectedItem = makeFeedbackItem({ status: "rejected", decision: "reject" }); + // Non-review-thread rejected feedback does not require a GitHub follow-up, + // so the run can be skipped entirely. + const rejectedItem = makeFeedbackItem({ + status: "rejected", + decision: "reject", + replyKind: "general_comment", + threadId: null, + }); const pr = await storage.addPR({ number: 106, title: "Verbose PR", @@ -6031,12 +6127,23 @@ test("runQueuedBabysitPR falls back to the next coding agent when enabled", asyn }); const evaluatedAgents: string[] = []; + let feedbackFetchCount = 0; const babysitter = new PRBabysitter( storage, makeWatcherGitHubService({ - fetchFeedbackItemsForPR: async () => [existingItem], + fetchFeedbackItemsForPR: async () => { + feedbackFetchCount += 1; + if (feedbackFetchCount === 1) return [existingItem]; + // The rejected thread was replied to and resolved on GitHub. + return [{ ...existingItem, threadResolved: true }]; + }, fetchPullSummary: async () => makePullSummary(pr), listFailingStatuses: async () => [], + postFollowUpForFeedbackItem: async (_octokit, _parsed, item, _body, options) => { + if (options?.resolve && item.threadId) { + existingItem.threadResolved = true; + } + }, }), { resolveAgent: async (agent) => agent, @@ -6065,6 +6172,7 @@ test("runQueuedBabysitPR falls back to the next coding agent when enabled", asyn const updated = await storage.getPR(pr.id); assert.equal(updated?.status, "watching"); assert.equal(updated?.feedbackItems[0]?.status, "rejected"); + assert.equal(updated?.feedbackItems[0]?.threadResolved, true); const logs = await storage.getLogs(pr.id); assert.ok(logs.some((log) => log.level === "warn" && log.message.includes("Falling back from claude to codex"))); diff --git a/server/babysitter.ts b/server/babysitter.ts index b28bdc8..3956cbe 100644 --- a/server/babysitter.ts +++ b/server/babysitter.ts @@ -69,6 +69,7 @@ import { applyEvaluationDecision, isFeedbackClosedStatus, markInProgress, + markReviewConversationResolved, markResolved, markFailed, markRetry, @@ -938,7 +939,11 @@ function collectAuditTrailErrors(params: { const errors: string[] = []; for (const item of followUpTasks) { - if (!hasAuditTrail(item, pr.feedbackItems, runStartedAtMs)) { + // Rejected review-thread items are closed with an explanation reply and + // conversation resolution; there is no code change to audit, so only the + // thread resolution is verified for them. + const isRejectedReviewThread = item.decision === "reject" && item.replyKind === "review_thread"; + if (!isRejectedReviewThread && !hasAuditTrail(item, pr.feedbackItems, runStartedAtMs)) { errors.push(`missing audit trail for ${item.id}`); } @@ -954,11 +959,24 @@ function collectAuditTrailErrors(params: { } function needsGitHubFollowUp(item: FeedbackItem, feedbackItems: FeedbackItem[]): boolean { - if (item.decision !== "accept") { - return false; - } - - if (item.status !== "queued" && item.status !== "in_progress") { + // Accepted feedback items awaiting applied work. + const isAcceptedWork = item.decision === "accept" + && (item.status === "queued" || item.status === "in_progress"); + + // Rejected review-thread items still need a closing reply (why it was + // rejected) plus conversation resolution, matching the agent prompt: + // "For rejected feedback: reply ... with what was done, or why it was + // rejected. Resolve the GitHub conversation after replying." + // PatchDeck's own status/audit-trail replies are excluded: they were + // rejected as "not new work", not as reviewer feedback, so they must not + // trigger another reply (that would loop on our own comments). + const rejectedInternalReply = item.decision === "reject" + && /PatchDeck status comment|Automation audit trail follow-up/i.test(item.statusReason ?? ""); + const isRejectedReviewThread = item.decision === "reject" + && item.replyKind === "review_thread" + && !rejectedInternalReply; + + if (!isAcceptedWork && !isRejectedReviewThread) { return false; } @@ -1100,12 +1118,19 @@ function buildFeedbackFollowUpBody( agentSummary?: string, ): string { const shortSha = headSha.trim() ? headSha.trim().slice(0, 7) : ""; - const headline = shortSha - ? `Addressed in commit \`${shortSha}\`.` - : "Addressed in the latest update."; + const rejected = item.decision === "reject"; + const headline = rejected + ? "Rejected — no code change made." + : shortSha + ? `Addressed in commit \`${shortSha}\`.` + : "Addressed in the latest update."; const parts = [headline]; + if (rejected && item.statusReason) { + parts.push("", `**Reason:** ${item.statusReason}`); + } + // For non-review-thread items the follow-up is posted as a top-level PR // comment, so include a reference to the original comment for a clear audit // trail linking the fix back to the feedback. @@ -5254,7 +5279,12 @@ export class PRBabysitter { }, }); - await updateItemStatus(item.id, STATUS_MESSAGES.resolved(headShaForFollowUp)); + await updateItemStatus( + item.id, + item.decision === "reject" + ? "**Rejected** — replied to the review thread and resolved the conversation." + : STATUS_MESSAGES.resolved(headShaForFollowUp), + ); } pr = await this.syncFeedbackForPR(pr.id, { @@ -5286,9 +5316,16 @@ export class PRBabysitter { if (followUpTasks.length > 0) { const resolvedIds = new Set(followUpTasks.map((item) => item.id)); - const resolvedItems = pr.feedbackItems.map((item) => - resolvedIds.has(item.id) ? markResolved(item) : item, - ); + const resolvedItems = pr.feedbackItems.map((item) => { + if (!resolvedIds.has(item.id)) { + return item; + } + // Rejected review threads keep their rejection decision; only the + // conversation resolution flag is advanced. + return item.decision === "reject" + ? markReviewConversationResolved(item) + : markResolved(item); + }); const resolvedCounters = countDecisions(resolvedItems); const resolvedPR = await this.storage.updatePR(pr.id, { feedbackItems: resolvedItems, From aa80f828006919a3b81061cca25a2c74e5b2dc41 Mon Sep 17 00:00:00 2001 From: cwbcheng Date: Thu, 6 Aug 2026 02:14:12 +0800 Subject: [PATCH 2/6] feat: write GitHub comments and replies in Chinese MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Localize the comments PatchDeck posts to GitHub: - Agent prompt now requires Simplified Chinese for every comment, thread reply, and summary. - Follow-up replies use Chinese headlines (已拒绝/已在提交/已在最新更新) with 原因 for rejected threads, and 正在回复 for quoted references. - Status messages (accepted/in-progress/needs-attention/verifying/resolved) are localized. - APP_STATUS_COMMENT_PATTERN now recognizes both English and Chinese status markers so recovery of existing status replies keeps working. Tests updated to assert the Chinese wording. --- server/babysitter.test.ts | 48 +++++++++++++++++++-------------------- server/babysitter.ts | 27 +++++++++++----------- server/github.ts | 2 +- 3 files changed, 39 insertions(+), 38 deletions(-) diff --git a/server/babysitter.test.ts b/server/babysitter.test.ts index 09658d5..27df6e4 100644 --- a/server/babysitter.test.ts +++ b/server/babysitter.test.ts @@ -2731,7 +2731,7 @@ test("babysitPR uses a CODEFACTORY_HOME worktree, passes GitHub context, and ver assert.deepEqual(postedFollowUps, [ { id: "gh-review-comment-1", - body: `Addressed in commit \`def456\`.\n\nRenamed the variable from \`foo\` to \`bar\` as requested.\n\n\n\n${APP_COMMENT_FOOTER}`, + body: `已在提交 \`def456\` 中处理。\n\nRenamed the variable from \`foo\` to \`bar\` as requested.\n\n\n\n${APP_COMMENT_FOOTER}`, }, ]); assert.equal(postedAgentComments.length, 1); @@ -3336,7 +3336,7 @@ test("babysitPR omits repository links in GitHub comments when disabled", async assert.deepEqual(postedFollowUps, [ { id: "gh-review-comment-1", - body: "Addressed in commit `def456`.\n\nRenamed the variable from `foo` to `bar` as requested.\n\n", + body: "已在提交 `def456` 中处理。\n\nRenamed the variable from `foo` to `bar` as requested.\n\n", }, ]); assert.equal(postedAgentComments.length, 1); @@ -3397,7 +3397,7 @@ test("babysitPR keeps acceptance local and logs best-effort reaction failures", makeFeedbackItem({ id: "gh-review-comment-3", author: "code-factory", - body: `Addressed in commit \`def456\`.\n\n${firstItem.auditToken}`, + body: `已在提交 \`def456\` 中处理。\n\n${firstItem.auditToken}`, bodyHtml: `

Addressed in commit def456.

${firstItem.auditToken}

`, sourceId: "3", sourceNodeId: "PRRC_kwDO_followup_1", @@ -3412,7 +3412,7 @@ test("babysitPR keeps acceptance local and logs best-effort reaction failures", makeFeedbackItem({ id: "gh-review-comment-4", author: "code-factory", - body: `Addressed in commit \`def456\`.\n\n${secondItem.auditToken}`, + body: `已在提交 \`def456\` 中处理。\n\n${secondItem.auditToken}`, bodyHtml: `

Addressed in commit def456.

${secondItem.auditToken}

`, sourceId: "4", sourceNodeId: "PRRC_kwDO_followup_2", @@ -3544,7 +3544,7 @@ test("babysitPR posts progress replies when GitHub progress replies are enabled" const followUp = makeFeedbackItem({ id: "gh-review-comment-2", author: "code-factory", - body: `Addressed in commit \`def456\`.\n\n${existingItem.auditToken}`, + body: `已在提交 \`def456\` 中处理。\n\n${existingItem.auditToken}`, bodyHtml: `

Addressed in commit def456.

${existingItem.auditToken}

`, sourceId: "2", sourceNodeId: "PRRC_kwDO_followup", @@ -3611,13 +3611,13 @@ test("babysitPR posts progress replies when GitHub progress replies are enabled" await babysitter.babysitPR(pr.id, "codex"); - const expectedAcceptedLine = "**Accepted** \u2014 queued for a code change."; + const expectedAcceptedLine = "**已接受** \u2014 已排队等待代码修改。"; const expectedAcceptedStatus = `${expectedAcceptedLine}\n\n${APP_COMMENT_FOOTER}`; const expectedFinalStatusBody = [ expectedAcceptedLine, - "**In progress** \u2014 applying the accepted fix.", - "**Verifying** \u2014 checking the applied changes.", - "**Resolved** \u2014 addressed in commit `def456`.", + "**进行中** \u2014 正在应用已接受的修复。", + "**验证中** \u2014 正在检查已应用的修改。", + "**已解决** \u2014 已在提交 `def456` 中处理。", "", APP_COMMENT_FOOTER, ].join("\n"); @@ -3648,7 +3648,7 @@ test("runQueuedBabysitPR recovers an existing status reply after restart", async const staleStatusReply = makeFeedbackItem({ id: "gh-review-comment-77", author: "alex-morgan-o", - body: "\u23f3 **Accepted** \u2014 queued for a code change.", + body: "\u23f3 **已接受** \u2014 已排队等待代码修改。", sourceId: "77", sourceNodeId: "PRRC_kwDO_status", sourceUrl: "https://github.com/octo/example/pull/42#discussion_r77", @@ -3770,8 +3770,8 @@ test("runQueuedBabysitPR recovers an existing status reply after restart", async await babysitter.runQueuedBabysitPR(pr.id, "codex"); - assert.ok(updatedStatusBodies.some((body) => body.includes("**In progress**"))); - assert.ok(updatedStatusBodies.at(-1)?.includes("**Resolved**")); + assert.ok(updatedStatusBodies.some((body) => body.includes("**进行中**"))); + assert.ok(updatedStatusBodies.at(-1)?.includes("**已解决**")); const [run] = await storage.listAgentRuns({ prId: pr.id }); assert.equal(run?.status, "completed"); assert.equal((run?.metadata?.statusReplyRefs as Record | undefined)?.[existingItem.id]?.commentDatabaseId, 77); @@ -3940,7 +3940,7 @@ test("babysitPR keeps failed GitHub progress replies concise", async () => { const body = statusReplyRefs.get(existingItem.id)?.body ?? ""; assert.match( body, - /\*\*Needs attention\*\* \u2014 automatic fix failed: TypeScript check failed/, + /\*\*需要关注\*\* \u2014 自动修复失败:TypeScript check failed/, ); } finally { delete process.env.CODEFACTORY_HOME; @@ -4119,7 +4119,7 @@ test("babysitPR treats footerless status replies as non-actionable", async () => await storage.updateConfig({ autoUpdateDocs: false }); const statusReply = makeFeedbackItem({ author: "octocat", - body: "**In progress** \u2014 applying the accepted fix.", + body: "**进行中** \u2014 正在应用已接受的修复。", bodyHtml: "

In progress - applying the accepted fix.

", decision: null, status: "pending", @@ -4689,7 +4689,7 @@ test("babysitPR replies to and resolves rejected review threads", async () => { assert.equal(postedFollowUps.length, 1, "rejected review thread should get a GitHub follow-up"); assert.equal(postedFollowUps[0]?.id, rejectedItem.id); assert.equal(postedFollowUps[0]?.resolve, true, "follow-up should resolve the conversation"); - assert.match(postedFollowUps[0]?.body ?? "", /Rejected/, "follow-up body should explain the rejection"); + assert.match(postedFollowUps[0]?.body ?? "", /已拒绝|原因/, "follow-up body should explain the rejection in Chinese"); assert.equal(resolvedThreads.includes(rejectedItem.threadId ?? ""), true, "thread should be resolved on GitHub"); assert.equal(updatedItem?.threadResolved, true, "feedback item should be marked thread-resolved"); @@ -5415,7 +5415,7 @@ test("babysitPR continues comment remediation when docs assessment fails", async const followUp = makeFeedbackItem({ id: "gh-review-comment-2", author: "code-factory", - body: `Addressed in commit \`def456\`.\n\n${existingItem.auditToken}`, + body: `已在提交 \`def456\` 中处理。\n\n${existingItem.auditToken}`, bodyHtml: `

Addressed in commit def456.

${existingItem.auditToken}

`, sourceId: "2", sourceNodeId: "PRRC_kwDO_followup", @@ -5529,7 +5529,7 @@ test("babysitPR retries accepted in-progress feedback items that still need GitH const followUp = makeFeedbackItem({ id: "gh-review-comment-2", author: "code-factory", - body: `Addressed in commit \`abc123\`.\n\n${existingItem.auditToken}`, + body: `已在提交 \`abc123\` 中处理。\n\n${existingItem.auditToken}`, bodyHtml: `

Addressed in commit abc123.

${existingItem.auditToken}

`, sourceId: "2", sourceNodeId: "PRRC_kwDO_followup", @@ -5609,7 +5609,7 @@ test("babysitPR retries accepted in-progress feedback items that still need GitH assert.deepEqual(postedFollowUps, [ { id: "gh-review-comment-1", - body: `Addressed in commit \`abc123\`.\n\n\n\n${APP_COMMENT_FOOTER}`, + body: `已在提交 \`abc123\` 中处理。\n\n\n\n${APP_COMMENT_FOOTER}`, }, ]); assert.deepEqual(resolvedThreads, ["PRRT_kwDO_example"]); @@ -5663,7 +5663,7 @@ test("resumeInterruptedRuns replays the persisted prompt when the PR head has no const followUp = makeFeedbackItem({ id: "gh-review-comment-2", author: "code-factory", - body: `Addressed in commit \`def456\`.\n\n${existingItem.auditToken}`, + body: `已在提交 \`def456\` 中处理。\n\n${existingItem.auditToken}`, bodyHtml: `

Addressed in commit def456.

${existingItem.auditToken}

`, sourceId: "2", sourceNodeId: "PRRC_kwDO_followup", @@ -6453,7 +6453,7 @@ test("babysitPR resolves lingering review threads without reposting an existing const priorFollowUp = makeFeedbackItem({ id: "gh-review-comment-2", author: "code-factory", - body: `Addressed in commit \`abc123\`.\n\n${existingItem.auditToken}`, + body: `已在提交 \`abc123\` 中处理。\n\n${existingItem.auditToken}`, bodyHtml: `

Addressed in commit abc123.

${existingItem.auditToken}

`, sourceId: "2", sourceNodeId: "PRRC_kwDO_followup", @@ -6700,7 +6700,7 @@ test("babysitPR reposts GitHub follow-up when an earlier audit trail used the wr const priorFollowUp = makeFeedbackItem({ id: "gh-review-comment-2", author: "code-factory", - body: `Addressed in commit \`abc123\`.\n\n${existingItem.auditToken}`, + body: `已在提交 \`abc123\` 中处理。\n\n${existingItem.auditToken}`, bodyHtml: `

Addressed in commit abc123.

${existingItem.auditToken}

`, sourceId: "2", sourceNodeId: "PRRC_kwDO_followup", @@ -6718,7 +6718,7 @@ test("babysitPR reposts GitHub follow-up when an earlier audit trail used the wr const correctedFollowUp = makeFeedbackItem({ id: "gh-review-comment-3", author: "code-factory", - body: `Addressed in commit \`abc123\`.\n\n${existingItem.auditToken}`, + body: `已在提交 \`abc123\` 中处理。\n\n${existingItem.auditToken}`, bodyHtml: `

Addressed in commit abc123.

${existingItem.auditToken}

`, sourceId: "3", sourceNodeId: "PRRC_kwDO_followup_corrected", @@ -6820,7 +6820,7 @@ test("babysitPR reposts GitHub follow-up when an earlier audit trail used the wr assert.deepEqual(postedFollowUps, [ { id: "gh-review-comment-1", - body: `Addressed in commit \`abc123\`.\n\n\n\n${APP_COMMENT_FOOTER}`, + body: `已在提交 \`abc123\` 中处理。\n\n\n\n${APP_COMMENT_FOOTER}`, }, ]); assert.deepEqual(resolvedThreads, ["PRRT_kwDO_example"]); @@ -7162,7 +7162,7 @@ test("babysitPR skips conflict resolution when PR is mergeable", async () => { const followUp = makeFeedbackItem({ id: "gh-review-comment-2", author: "code-factory", - body: `Addressed in commit \`abc123\`.\n\n${existingItem.auditToken}`, + body: `已在提交 \`abc123\` 中处理。\n\n${existingItem.auditToken}`, bodyHtml: `

Addressed.

${existingItem.auditToken}

`, sourceId: "2", sourceNodeId: "PRRC_kwDO_followup", diff --git a/server/babysitter.ts b/server/babysitter.ts index 3956cbe..2ed8d45 100644 --- a/server/babysitter.ts +++ b/server/babysitter.ts @@ -241,17 +241,17 @@ const defaultBabysitterRuntime: BabysitterRuntime = { }; const STATUS_MESSAGES = { - accepted: "**Accepted** — queued for a code change.", - agentRunning: (_agent: CodingAgent) => "**In progress** — applying the accepted fix.", + accepted: "**已接受** — 已排队等待代码修改。", + agentRunning: (_agent: CodingAgent) => "**进行中** — 正在应用已接受的修复。", agentFailed: (_agent: CodingAgent, reason?: string) => reason - ? `**Needs attention** — automatic fix failed: ${reason}` - : "**Needs attention** — automatic fix failed.", - agentCompleted: "**Verifying** — checking the applied changes.", + ? `**需要关注** — 自动修复失败:${reason}` + : "**需要关注** — 自动修复失败。", + agentCompleted: "**验证中** — 正在检查已应用的修改。", resolved: (headSha: string) => { const shortSha = headSha.trim().slice(0, 7); return shortSha - ? `**Resolved** — addressed in commit \`${shortSha}\`.` - : "**Resolved** — addressed in the latest update."; + ? `**已解决** — 已在提交 \`${shortSha}\` 中处理。` + : "**已解决** — 已在最新更新中处理。"; }, } as const; @@ -634,6 +634,7 @@ function buildCodeOwnerFallbackPrompt(params: { " - Reply directly to the GitHub comment/thread with what was done, or why it was rejected.", " - Resolve the GitHub conversation after replying.", " - If the item is not a resolvable review thread, leave the reply/comment and note that there was no thread to resolve.", + "Language: write every GitHub comment, thread reply, and summary in Simplified Chinese.", "6. When all valid feedback is handled:", " - Confirm the worktree is clean except for intended changes.", " - Commit changes if any were made.", @@ -1120,15 +1121,15 @@ function buildFeedbackFollowUpBody( const shortSha = headSha.trim() ? headSha.trim().slice(0, 7) : ""; const rejected = item.decision === "reject"; const headline = rejected - ? "Rejected — no code change made." + ? "已拒绝 — 未做代码修改。" : shortSha - ? `Addressed in commit \`${shortSha}\`.` - : "Addressed in the latest update."; + ? `已在提交 \`${shortSha}\` 中处理。` + : "已在最新更新中处理。"; const parts = [headline]; if (rejected && item.statusReason) { - parts.push("", `**Reason:** ${item.statusReason}`); + parts.push("", `**原因:** ${item.statusReason}`); } // For non-review-thread items the follow-up is posted as a top-level PR @@ -1139,7 +1140,7 @@ function buildFeedbackFollowUpBody( const preview = firstLine.length > 120 ? firstLine.slice(0, 120) + "…" : firstLine; parts.push( "", - `> Responding to [comment by @${item.author}](${item.sourceUrl}):`, + `> 正在回复 [@${item.author} 的评论](${item.sourceUrl}):`, `> ${preview}`, ); } @@ -5282,7 +5283,7 @@ export class PRBabysitter { await updateItemStatus( item.id, item.decision === "reject" - ? "**Rejected** — replied to the review thread and resolved the conversation." + ? "**已拒绝** — 已回复评论线程并解决会话。" : STATUS_MESSAGES.resolved(headShaForFollowUp), ); } diff --git a/server/github.ts b/server/github.ts index 3b9386c..cc2a057 100644 --- a/server/github.ts +++ b/server/github.ts @@ -474,7 +474,7 @@ export function buildFeedbackAuditToken(feedbackId: string): string { const APP_COMMENT_FOOTER_PATTERN = /Posted by \[[^\]]+\]\(https:\/\/github\.com\/jeremymcs\/patchdeck\)/i; const AGENT_COMMAND_COMMENT_MARKER = ""; const AUDIT_TRAIL_COMMENT_PATTERN = //i; -export const APP_STATUS_COMMENT_PATTERN = /\*\*(?:Accepted|Agent running|Agent failed|Agent completed|In progress|Needs attention|Verifying|Resolved)\*\*\s*(?:[—-]|$)/i; +export const APP_STATUS_COMMENT_PATTERN = /\*\*(?:Accepted|Agent running|Agent failed|Agent completed|In progress|Needs attention|Verifying|Resolved|已接受|进行中|需要关注|验证中|已解决)\*\*\s*(?:[—-]|$)/i; function classifyNonActionableAppFeedback(body: string): string | null { if (body.includes(AGENT_COMMAND_COMMENT_MARKER)) { From a2df6021cba141c6721fd709923cd93a9dad5e43 Mon Sep 17 00:00:00 2001 From: cwbcheng Date: Thu, 6 Aug 2026 02:22:00 +0800 Subject: [PATCH 3/6] fix: prevent reply loop on PatchDeck's own audit-trail comments The internal-reply exclusion in needsGitHubFollowUp only matched 'PatchDeck status comment' and 'Automation audit trail follow-up', but classifyNonActionableAppFeedback also produces 'PatchDeck audit trail comment' and 'PatchDeck agent command comment'. A rejected item carrying one of those reasons was treated as real reviewer feedback, which made PatchDeck reply to its own follow-up comment, producing a new audit trail, which was then replied to again - an infinite loop. Widen the exclusion to all PatchDeck-authored marker reasons and add a regression test that a rejected audit-trail comment never triggers a follow-up reply. --- server/babysitter.test.ts | 62 +++++++++++++++++++++++++++++++++++++++ server/babysitter.ts | 2 +- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/server/babysitter.test.ts b/server/babysitter.test.ts index 27df6e4..a527aae 100644 --- a/server/babysitter.test.ts +++ b/server/babysitter.test.ts @@ -4609,6 +4609,68 @@ test("babysitPR does not pull rejected or resolved items into in_progress", asyn delete process.env.CODEFACTORY_HOME; }); +test("babysitPR does not re-reply to its own rejected audit-trail comments", async () => { + const storage = new MemStorage(); + await storage.updateConfig({ autoUpdateDocs: false }); + // A comment PatchDeck itself posted (contains the audit trail marker) that was + // rejected must not trigger another follow-up reply, or it would loop forever. + const ownReply = makeFeedbackItem({ + id: "gh-review-comment-own-audit-reply", + author: "cwbcheng", + body: `已在提交 \`abc123\` 中处理。\n\n`, + status: "rejected", + decision: "reject", + statusReason: "PatchDeck audit trail comment", + }); + 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: [ownReply], + accepted: 0, + rejected: 1, + flagged: 0, + testsPassed: null, + lintPassed: null, + lastChecked: null, + }); + + let postedFollowUpCount = 0; + const babysitter = new PRBabysitter( + storage, + makeWatcherGitHubService({ + fetchFeedbackItemsForPR: async () => [ownReply], + fetchPullSummary: async () => makePullSummary(pr), + listFailingStatuses: async () => [], + postFollowUpForFeedbackItem: async () => { + postedFollowUpCount += 1; + }, + }), + { + resolveAgent: async () => "codex", + ciPollIntervalMs: 0, + evaluateFixNecessityWithAgent: async () => { + throw new Error("own audit-trail reply should not be evaluated"); + }, + applyFixesWithAgent: async () => { + throw new Error("own audit-trail reply should not trigger a fix run"); + }, + runCommand: makeGitRunCommand(), + }, + ); + + await babysitter.babysitPR(pr.id, "codex"); + + assert.equal(postedFollowUpCount, 0, "PatchDeck's own rejected audit-trail comment must not be re-replied"); + const updated = await storage.getPR(pr.id); + const item = updated?.feedbackItems.find((i) => i.id === ownReply.id); + assert.equal(item?.status, "rejected"); +}); + test("babysitPR replies to and resolves rejected review threads", async () => { const storage = new MemStorage(); await storage.updateConfig({ autoUpdateDocs: false }); diff --git a/server/babysitter.ts b/server/babysitter.ts index 2ed8d45..417460d 100644 --- a/server/babysitter.ts +++ b/server/babysitter.ts @@ -972,7 +972,7 @@ function needsGitHubFollowUp(item: FeedbackItem, feedbackItems: FeedbackItem[]): // rejected as "not new work", not as reviewer feedback, so they must not // trigger another reply (that would loop on our own comments). const rejectedInternalReply = item.decision === "reject" - && /PatchDeck status comment|Automation audit trail follow-up/i.test(item.statusReason ?? ""); + && /PatchDeck (agent command|audit trail|status) comment|Automation audit trail follow-up/i.test(item.statusReason ?? ""); const isRejectedReviewThread = item.decision === "reject" && item.replyKind === "review_thread" && !rejectedInternalReply; From 86769944dda3eb42520322571d1f80f686564c6a Mon Sep 17 00:00:00 2001 From: cwbcheng Date: Thu, 6 Aug 2026 04:44:01 +0800 Subject: [PATCH 4/6] fix: finalize code-owner fallback worktree before cleanup 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). Previously PatchDeck marked the fallback run completed and removed the worktree, discarding the agent's edits and leaving the PR branch and review threads untouched. After a successful fallback agent run, PatchDeck now: - commits any uncommitted agent edits in the worktree, - pushes the local head to the PR head branch, - verifies the remote head matches the local head. Adds a regression test that a fallback agent leaving uncommitted edits results in git add/commit/push to the PR branch. --- server/babysitter.test.ts | 108 ++++++++++++++++++++++++++++++++++++++ server/babysitter.ts | 100 +++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) diff --git a/server/babysitter.test.ts b/server/babysitter.test.ts index a527aae..3d46b70 100644 --- a/server/babysitter.test.ts +++ b/server/babysitter.test.ts @@ -6419,6 +6419,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 417460d..d3452e2 100644 --- a/server/babysitter.ts +++ b/server/babysitter.ts @@ -3366,6 +3366,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; @@ -3508,6 +3594,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(); From 13cd24f60651636acaf97f1b35c5ae2e4d2bb49b Mon Sep 17 00:00:00 2001 From: cwbcheng Date: Thu, 6 Aug 2026 05:05:54 +0800 Subject: [PATCH 5/6] fix: increase agent evaluation and apply timeouts for slow models The DeepSeek-backed codex agent frequently times out on long review comments: evaluation had a 3-minute cap and apply had a 15-minute cap, which was not enough for multi-file fixes (e.g. 21 generated fixtures). When apply timed out mid-run, the uncommitted worktree edits were discarded. Raise evaluation to 7 minutes and the default apply timeout to 30 minutes (matching the code-owner fallback timeout) so complex review threads can be evaluated and fixed to completion. --- server/agentRunner.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/agentRunner.ts b/server/agentRunner.ts index bfe45b1..656d698 100644 --- a/server/agentRunner.ts +++ b/server/agentRunner.ts @@ -232,7 +232,7 @@ export async function evaluateFixNecessityWithAgent(params: { outputFile, extractionPrompt, ], settings), - { cwd, timeoutMs: 180000 }, + { cwd, timeoutMs: 420000 }, ); if (result.code !== 0) { @@ -268,7 +268,7 @@ export async function evaluateFixNecessityWithAgent(params: { const result = await runAgentCommand( "claude", claudeArgs, - { cwd, timeoutMs: 180000 }, + { cwd, timeoutMs: 420000 }, ); if (result.code !== 0) { @@ -288,7 +288,7 @@ export async function applyFixesWithAgent(params: { onStdoutChunk?: (chunk: string) => void; onStderrChunk?: (chunk: string) => void; }): Promise { - const { agent, cwd, prompt, settings, env, timeoutMs = 900000, onStdoutChunk, onStderrChunk } = params; + const { agent, cwd, prompt, settings, env, timeoutMs = 1800000, onStdoutChunk, onStderrChunk } = params; if (agent === "codex") { const result = await runAgentCommand( From c01f691f7cd1ca41103968d64cbb43ea42045b70 Mon Sep 17 00:00:00 2001 From: cwbcheng Date: Fri, 7 Aug 2026 01:17:03 +0800 Subject: [PATCH 6/6] fix: stage agent-resolved conflicts before checking unmerged files --- server/babysitter.test.ts | 91 +++++++++++++++++++++++++++++++++++++++ server/babysitter.ts | 18 ++++++++ 2 files changed, 109 insertions(+) diff --git a/server/babysitter.test.ts b/server/babysitter.test.ts index 3d46b70..280bd90 100644 --- a/server/babysitter.test.ts +++ b/server/babysitter.test.ts @@ -7619,6 +7619,97 @@ test("babysitPR records conflict repair failure when agent leaves conflict marke delete process.env.CODEFACTORY_HOME; }); +test("babysitPR commits agent-resolved conflicts even when the agent does not stage them", 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 pullSummary = makePullSummary(pr, { mergeable: false }); + const gitRunner = makeGitRunCommand({ + localHeadSha: "merge123", + remoteHeadSha: "merge123", + }); + // The agent resolves the conflict by editing the file but never stages it, + // so the unmerged index entries persist until PatchDeck stages them. + let staged = false; + let conflictAgentCalled = false; + + const babysitter = new PRBabysitter( + storage, + { + buildOctokit: async () => ({}) as never, + fetchFeedbackItemsForPR: async () => [], + fetchPullSummary: async () => pullSummary, + listFailingStatuses: async () => [], + checkCISettled: async () => true, + listOpenPullsForRepo: async () => [], + postFollowUpForFeedbackItem: async () => undefined, + resolveReviewThread: async () => undefined, + resolveGitHubAuthToken: async () => "test-token", + addReactionToComment: async () => {}, + postStatusReplyForFeedbackItem: async () => null, + updateStatusReply: async () => {}, + }, + { + resolveAgent: async () => "codex", + ciPollIntervalMs: 0, + evaluateFixNecessityWithAgent: async () => ({ + needsFix: false, + reason: "No fix needed", + }), + applyFixesWithAgent: async ({ prompt }) => { + conflictAgentCalled = true; + assert.match(prompt, /merge conflicts/i); + return { code: 0, stdout: "resolved\n", stderr: "" }; + }, + runCommand: async (command: string, args: string[], opts?: Record) => { + if (command === "git" && args[0] === "merge") { + return { code: 1, stdout: "", stderr: "CONFLICT" }; + } + if (command === "git" && args[0] === "add") { + staged = true; + return { code: 0, stdout: "", stderr: "" }; + } + if (command === "git" && args[0] === "diff" && args[1] === "--name-only" && args[2] === "--diff-filter=U") { + return { code: 0, stdout: staged ? "" : "src/conflict.ts\n", stderr: "" }; + } + return gitRunner(command, args, opts); + }, + }, + ); + + await babysitter.babysitPR(pr.id, "codex"); + + const updated = await storage.getPR(pr.id); + const runs = await storage.listAgentRuns({ prId: pr.id }); + const logs = await storage.getLogs(pr.id); + + assert.equal(conflictAgentCalled, true); + assert.equal(staged, true, "PatchDeck should stage agent-resolved conflict files"); + assert.equal(updated?.status, "watching"); + assert.equal(runs[0]?.status, "completed"); + assert.ok(logs.some((log) => log.phase === "conflict" && log.message.includes("Merge conflicts resolved and committed"))); + + delete process.env.CODEFACTORY_HOME; +}); + test("babysitPR skips conflict resolution when autoResolveMergeConflicts is disabled", async () => { const storage = new MemStorage(); await storage.updateConfig({ autoResolveMergeConflicts: false, autoUpdateDocs: false }); diff --git a/server/babysitter.ts b/server/babysitter.ts index d3452e2..e53273e 100644 --- a/server/babysitter.ts +++ b/server/babysitter.ts @@ -4895,6 +4895,24 @@ export class PRBabysitter { metadata: { code: conflictResult.code }, }); + // The agent resolves conflicts by editing worktree files but usually + // does not stage them. Stage everything before the unmerged-index + // check below, otherwise resolved files still appear as unmerged and + // the run is falsely declared failed. Any leftover conflict markers + // are caught by the marker check that follows. + const stageResolvedConflicts = await runLoggedCommand({ + currentPrId: pr.id, + command: "git", + args: ["add", "-A"], + cwd: worktreePath, + timeoutMs: 30000, + phase: "conflict.agent", + successMessage: "Staged resolved merge conflict files", + }); + if (stageResolvedConflicts.code !== 0) { + throw new Error(formatGitFailure("staging resolved merge conflicts", stageResolvedConflicts)); + } + const unresolvedAfterAgent = await this.runtime.runCommand("git", ["diff", "--name-only", "--diff-filter=U"], { cwd: worktreePath, timeoutMs: 5000,