From 0f116de72e89a2ab2b9954a45b6bd1e1fb721cd5 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 4 Nov 2026 09:30:40 +0800 Subject: [PATCH 1/4] fix(server): PR watches wake again once checks finish after an early failure When one check failed while others were still running, the watch woke the agent once for the failure and then never again: "checks passed" and the settled wake both require zero failures. Agents that ended their turn to wait for the remaining checks stayed waiting indefinitely. A failed gate now owes one more wake, sent when the last pending check finishes, listing the checks still failing. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/pullRequestWatch.test.ts | 11 ++++++++++- apps/server/src/orchestration-v2/pullRequestWatch.ts | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 6ab019164949..6df9fb0e6169 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -79,13 +79,22 @@ const remark = ( const noRemarks: ReadonlyArray = []; describe("evaluatePullRequestWatch", () => { - it("reports each failure at once, even while another check never finishes", () => { + it("reports each failure at once, and again once the other checks finish", () => { const bot = check("CodeRabbit", "pending"); const first = detail({ checks: [check("lint", "failure"), check("test", "pending"), bot] }); const lint = evaluatePullRequestWatch(watch(), first, noRemarks); assert.deepEqual(lint.changes, [{ kind: "checks-failed", failed: [check("lint", "failure")] }]); assert.deepEqual(evaluatePullRequestWatch(lint.next, first, noRemarks).changes, []); + const finished = detail({ + checks: [check("lint", "failure"), check("test", "success"), check("CodeRabbit", "success")], + }); + const settled = evaluatePullRequestWatch(lint.next, finished, noRemarks); + assert.deepEqual(settled.changes, [ + { kind: "checks-failed", failed: [check("lint", "failure")] }, + ]); + assert.deepEqual(evaluatePullRequestWatch(settled.next, finished, noRemarks).changes, []); + const second = detail({ checks: [check("lint", "failure"), check("test", "failure"), bot] }); const test = evaluatePullRequestWatch(lint.next, second, noRemarks); assert.deepEqual(test.changes, [{ kind: "checks-failed", failed: [check("test", "failure")] }]); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index f4c000084723..c1dfe7ddbbd1 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -51,6 +51,7 @@ export function evaluatePullRequestWatch( let passedChecks = headMoved ? [] : watch.passedChecks; let owedChecks = headMoved ? [] : (watch.owedChecks ?? []); if (detail.checks.length > 0) { + const owedBefore = owedChecks; const failed = detail.checks.filter(isFailedCheck); const newlyFailed = failed.filter((check) => !failedChecks.includes(check.name)); if (newlyFailed.length > 0) changes.push({ kind: "checks-failed", failed: newlyFailed }); @@ -86,6 +87,14 @@ export function evaluatePullRequestWatch( changes.push({ kind: "checks-passed", count: detail.checks.length, required: false }); owedChecks = []; } + // A failed gate never reports passed, so it owes one wake when the checks still running finish. + if (!passedNow && failed.length > 0) { + const pending = detail.checks.filter((check) => check.status === "pending"); + if (pending.length === 0 && owedBefore.length > 0 && newlyFailed.length === 0) { + changes.push({ kind: "checks-failed", failed }); + } + owedChecks = pending.map((check) => check.name); + } passed = passedNow; passedChecks = passedNow ? gateNames : []; } From f89c75ba73cf2fbfe0b80fa7b7894f7fa37635ad Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 4 Nov 2026 12:00:51 +0800 Subject: [PATCH 2/4] fix(server): the extra failure wake fires once, not again for later checks Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/pullRequestWatch.test.ts | 4 ++++ apps/server/src/orchestration-v2/pullRequestWatch.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 6df9fb0e6169..2aebb0a1083b 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -94,6 +94,10 @@ describe("evaluatePullRequestWatch", () => { { kind: "checks-failed", failed: [check("lint", "failure")] }, ]); assert.deepEqual(evaluatePullRequestWatch(settled.next, finished, noRemarks).changes, []); + const late = detail({ checks: [...finished.checks, check("docs", "pending")] }); + const lateRun = evaluatePullRequestWatch(settled.next, late, noRemarks); + const lateDone = detail({ checks: [...finished.checks, check("docs", "success")] }); + assert.deepEqual(evaluatePullRequestWatch(lateRun.next, lateDone, noRemarks).changes, []); const second = detail({ checks: [check("lint", "failure"), check("test", "failure"), bot] }); const test = evaluatePullRequestWatch(lint.next, second, noRemarks); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index c1dfe7ddbbd1..07ca1bc59732 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -93,7 +93,8 @@ export function evaluatePullRequestWatch( if (pending.length === 0 && owedBefore.length > 0 && newlyFailed.length === 0) { changes.push({ kind: "checks-failed", failed }); } - owedChecks = pending.map((check) => check.name); + const owing = newlyFailed.length > 0 || owedBefore.length > 0; + owedChecks = owing ? pending.map((check) => check.name) : []; } passed = passedNow; passedChecks = passedNow ? gateNames : []; From 4304fb9893c45416a4ced64b72f9d70b55d4d466 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 08:59:23 +0000 Subject: [PATCH 3/4] fix(server): owed failure wake waits for checks held for approval --- .../server/src/orchestration-v2/pullRequestWatch.test.ts | 9 +++++++++ apps/server/src/orchestration-v2/pullRequestWatch.ts | 9 ++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index d54d2512141c..49befb78eb4d 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -230,6 +230,15 @@ describe("evaluatePullRequestWatch", () => { const lateRun = evaluatePullRequestWatch(settled.next, late, noRemarks); const lateDone = detail({ checks: [...finished.checks, check("docs", "success")] }); assert.deepEqual(evaluatePullRequestWatch(lateRun.next, lateDone, noRemarks).changes, []); + const held = { ...check("test", "action-required"), url: run(3) }; + const heldRead = detail({ checks: [check("lint", "failure"), held] }); + const hold = evaluatePullRequestWatch(lint.next, heldRead, noRemarks); + assert.deepEqual(hold.changes, [{ kind: "checks-awaiting-approval", checks: [held] }]); + const ran = { ...check("test", "success"), url: `${run(3)}/job/1` }; + const heldDone = detail({ checks: [check("lint", "failure"), ran] }); + assert.deepEqual(evaluatePullRequestWatch(hold.next, heldDone, noRemarks).changes, [ + { kind: "checks-failed", failed: [check("lint", "failure")] }, + ]); const second = detail({ checks: [check("lint", "failure"), check("test", "failure"), bot] }); const test = evaluatePullRequestWatch(lint.next, second, noRemarks); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index e2fbccd0311c..43aef85dca75 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -139,12 +139,15 @@ export function evaluatePullRequestWatch( } // A failed gate never reports passed, so it owes one wake when the checks still running finish. if (!passedNow && failed.length > 0) { - const pending = checks.filter((check) => check.status === "pending"); - if (pending.length === 0 && owedBefore.length > 0 && newlyFailed.length === 0) { + const unfinished = checks + .filter((check) => check.status === "pending" || check.status === "action-required") + .map((check) => check.name); + const holding = unfinished.length > 0 || awaitingApproval.length > 0; + if (!holding && owedBefore.length > 0 && newlyFailed.length === 0) { changes.push({ kind: "checks-failed", failed }); } const owing = newlyFailed.length > 0 || owedBefore.length > 0; - owedChecks = owing ? pending.map((check) => check.name) : []; + owedChecks = owing && holding ? (unfinished.length > 0 ? unfinished : owedBefore) : []; } passed = passedNow; passedChecks = passedNow ? gateNames : []; From 7cf57954563470c30ec6206a8715e842eb265de4 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 09:12:32 +0000 Subject: [PATCH 4/4] fix(server): approval holds missing from a read keep the failure wake owed --- .../src/orchestration-v2/pullRequestWatch.test.ts | 12 ++++++++++++ apps/server/src/orchestration-v2/pullRequestWatch.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 49befb78eb4d..605b01f0a206 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -239,6 +239,18 @@ describe("evaluatePullRequestWatch", () => { assert.deepEqual(evaluatePullRequestWatch(hold.next, heldDone, noRemarks).changes, [ { kind: "checks-failed", failed: [check("lint", "failure")] }, ]); + const gate = { ...check("CI", "action-required"), url: run(4) }; + const gateRead = evaluatePullRequestWatch(watch(), detail({ checks: [gate] }), noRemarks); + const omitted = detail({ checks: [check("lint", "failure")] }); + const lintOnly = evaluatePullRequestWatch(gateRead.next, omitted, noRemarks); + assert.deepEqual(lintOnly.changes, [ + { kind: "checks-failed", failed: [check("lint", "failure")] }, + ]); + const gateRan = { ...check("e2e", "success"), url: `${run(4)}/job/1` }; + const gateDone = detail({ checks: [check("lint", "failure"), gateRan] }); + assert.deepEqual(evaluatePullRequestWatch(lintOnly.next, gateDone, noRemarks).changes, [ + { kind: "checks-failed", failed: [check("lint", "failure")] }, + ]); const second = detail({ checks: [check("lint", "failure"), check("test", "failure"), bot] }); const test = evaluatePullRequestWatch(lint.next, second, noRemarks); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 43aef85dca75..8e1c00cd2b25 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -147,7 +147,7 @@ export function evaluatePullRequestWatch( changes.push({ kind: "checks-failed", failed }); } const owing = newlyFailed.length > 0 || owedBefore.length > 0; - owedChecks = owing && holding ? (unfinished.length > 0 ? unfinished : owedBefore) : []; + owedChecks = owing && holding ? [...unfinished, ...awaitingApproval] : []; } passed = passedNow; passedChecks = passedNow ? gateNames : [];