From ceb5c6c7cdb4ef41d8ba1c00ad0773706b53a338 Mon Sep 17 00:00:00 2001 From: Daniel Genis Date: Tue, 21 Apr 2026 12:13:41 +0200 Subject: [PATCH] fix(reconcile): fix race condition breaking auto-resume edge detection PR watcher was updating prChecksStatus in DB before enqueueing reconcile, so by the time the reconciler ran, prev.checks already equaled the new status and the edge transition was never detected. - Remove prChecksStatus/prReviewStatus/prState updates from pr-watcher - Add patchPrStatusFields() helper to executor, called after edge-triggered actions (resumeAgent, launchReview, PR-related transitions) - Reconciler now correctly detects edges: reads old value from DB, compares to fresh GitHub status, fires action, then patches DB --- apps/api/src/services/reconcile-executor.ts | 26 +++++++++++++++++++++ apps/api/src/workers/pr-watcher-worker.ts | 17 +++----------- 2 files changed, 29 insertions(+), 14 deletions(-) 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) {