diff --git a/apps/api/src/services/reconcile-executor.ts b/apps/api/src/services/reconcile-executor.ts index d2113a13..746a38ff 100644 --- a/apps/api/src/services/reconcile-executor.ts +++ b/apps/api/src/services/reconcile-executor.ts @@ -21,6 +21,22 @@ export type ExecuteOutcome = | { status: "skipped"; reason: string } | { status: "error"; reason: string; error: unknown }; +/** + * Patch PR status fields in the task row after an edge-triggered action. + * This prevents duplicate edge detections on subsequent reconcile passes. + */ +async function patchPrStatusFields(taskId: string, snapshot: WorldSnapshot): Promise { + if (snapshot.run.kind !== "repo" || !snapshot.pr) return; + await db + .update(tasks) + .set({ + prChecksStatus: snapshot.pr.checksStatus, + prReviewStatus: snapshot.pr.reviewStatus, + prState: snapshot.pr.state, + }) + .where(eq(tasks.id, taskId)); +} + /** * Apply a reconcile action. All DB mutations are CAS-gated on * updated_at == snapshot.run.status.updatedAt so a decision made from @@ -142,6 +158,13 @@ async function applyRepoTransition( try { await taskService.transitionTask(id, action.to, action.trigger, action.reason); await scheduleBackoffReconcile(snapshot.run.ref, patch.reconcileBackoffUntil); + + // Patch PR status fields if this was a PR-related edge trigger, to prevent + // duplicate edge detections on subsequent reconcile passes. + if (action.trigger?.match(/ci_failing|review_changes|conflicts/)) { + await patchPrStatusFields(id, snapshot); + } + return { status: "applied", reason: `transition:${action.to}` }; } catch (err) { // StateRaceError means another worker won; treat as stale. @@ -375,6 +398,8 @@ async function applyResumeAgent( }, { jobId: `${taskId}-${jobSuffix}-${Date.now()}` }, ); + + await patchPrStatusFields(taskId, snapshot); return { status: "applied", reason: `resume:${action.resumeReason}` }; } @@ -386,6 +411,7 @@ async function applyLaunchReview(snapshot: WorldSnapshot): Promise null); if (!prData) continue; - const checkRuns = await platform.getCIChecks(ri, prData.headSha).catch(() => []); const reviewsData = await platform.getReviews(ri, prNumber).catch(() => []); - const checksStatus = determineCheckStatus(checkRuns); const reviewResult = determineReviewStatus(reviewsData); const reviewStatus = reviewResult.status; let reviewComments = reviewResult.comments; @@ -136,20 +134,11 @@ export function startPrWatcherWorker() { } catch {} } - // Conflicts override the raw checks status — once we've recorded - // conflicts, keep that label until mergeable flips back. - const effectiveChecksStatus = - task.prChecksStatus === "conflicts" && prData.mergeable === false - ? "conflicts" - : checksStatus; - - // Write all PR fields in one update. The reconciler reads these - // from the snapshot to decide the next action. + // Only update non-edge-triggering fields. The reconciler handles + // prChecksStatus, prReviewStatus, prState updates after detecting + // edge transitions — updating them here would defeat edge detection. const updates: Record = { prNumber, - prState: prData.merged ? "merged" : prData.state, - prChecksStatus: effectiveChecksStatus, - prReviewStatus: reviewStatus, updatedAt: new Date(), }; if (reviewComments) {