diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 3607e7272273d..5410f2ff29c4b 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4284,6 +4284,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(existing.endedWatch?.checksAwaitedSince !== undefined && { checksAwaitedSince: existing.endedWatch.checksAwaitedSince, }), + ...(existing.endedWatch?.checksPending !== undefined && { + checksPending: existing.endedWatch.checksPending, + }), remarksThrough: startedAt, remarkIds: [], wakes: 0, diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index f37a69c08ee0b..02737766f3b67 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -184,6 +184,7 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc left.remarkIds.join("\n") === right.remarkIds.join("\n") && left.conflicting === right.conflicting && left.wakes === right.wakes && + left.checksPending === right.checksPending && left.checksAwaitedSince === right.checksAwaitedSince ); } diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index dece3e3449831..476e9685f11db 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -236,6 +236,17 @@ describe("evaluatePullRequestWatch", () => { ]); }); + it("reports completion after a known failure when the remaining checks finish", () => { + const optional = check("windows-arm-optional", "failure"); + const pending = detail({ checks: [optional, check("gate", "pending")] }); + const first = evaluatePullRequestWatch(watch(), pending, noRemarks); + const done = detail({ checks: [optional, check("gate", "success")] }); + const final = evaluatePullRequestWatch(first.next, done, noRemarks); + assert.deepEqual(final.changes, [{ kind: "checks-settled", count: 2, failed: 1 }]); + assert.isFalse(final.next.passed); + assert.deepEqual(evaluatePullRequestWatch(final.next, done, noRemarks).changes, []); + }); + it("reports passed once the required checks pass, whatever the others do", () => { const required = (name: string, status: PullRequestCheck["status"]) => ({ ...check(name, status), diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 046c48e1858bc..39e374fde3b76 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -17,6 +17,7 @@ const SNIPPET_LENGTH = 200; export type PullRequestWatchChange = | { readonly kind: "checks-failed"; readonly failed: ReadonlyArray } | { readonly kind: "checks-passed"; readonly count: number; readonly required: boolean } + | { readonly kind: "checks-settled"; readonly count: number; readonly failed: number } | { readonly kind: "checks-awaiting-approval"; readonly checks: ReadonlyArray } | { readonly kind: "remarks"; readonly remarks: ReadonlyArray } | { readonly kind: "no-checks"; readonly minutes: number } @@ -54,6 +55,7 @@ export function evaluatePullRequestWatch( let passed = headMoved ? false : watch.passed; let passedChecks = headMoved ? [] : watch.passedChecks; let owedChecks = headMoved ? [] : (watch.owedChecks ?? []); + let checksPending = headMoved ? false : (watch.checksPending ?? false); let awaitingApproval = headMoved ? [] : (watch.awaitingApproval ?? []); let approvalAnnounced = headMoved ? false : (watch.approvalAnnounced ?? false); // An unavailable approval read cannot establish success or that no checks exist. @@ -136,6 +138,18 @@ export function evaluatePullRequestWatch( changes.push({ kind: "checks-passed", count: checks.length, required: false }); owedChecks = []; } + const pendingNow = + awaitingApproval.length > 0 || + checks.some((check) => check.status === "pending" || check.status === "action-required"); + if ( + checksPending && + !pendingNow && + failed.length > 0 && + !changes.some((change) => change.kind === "checks-failed" || change.kind === "checks-passed") + ) { + changes.push({ kind: "checks-settled", count: checks.length, failed: failed.length }); + } + checksPending = pendingNow; passed = passedNow; passedChecks = passedNow ? gateNames : []; } @@ -205,6 +219,7 @@ export function evaluatePullRequestWatch( conflicting, wakes, checksAwaitedSince, + checksPending, }, exhausted: commentsOnly && wakes >= PULL_REQUEST_WATCH_WAKE_LIMIT, }; @@ -251,6 +266,10 @@ function changeLines( return [ `- All ${change.count} ${change.required ? "required " : ""}${change.count === 1 ? "check" : "checks"} passed${context.commit}.`, ]; + case "checks-settled": + return [ + `- All ${change.count} checks have finished${context.commit}; ${change.failed} remain failed or need attention. Inspect the results before merging.`, + ]; case "remarks": return [ `- ${change.remarks.length} new ${change.remarks.length === 1 ? "comment" : "comments"}:`, @@ -273,6 +292,7 @@ function changeLines( const SUMMARY: Record = { "checks-failed": "checks failed", "checks-passed": "checks passed", + "checks-settled": "checks finished", "checks-awaiting-approval": "checks await approval", remarks: "new comments", "no-checks": "no checks reported", diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index a49e323770cc1..bd9c3cb8147ee 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3626,6 +3626,9 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { approval ? [runUrl] : ["test"], ); // The fresh reactor reloads the nonempty obligation from the persisted watch. + assert.isTrue( + (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.checksPending, + ); const restarted = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); yield* restarted.sweep; yield* worker.drain(); @@ -3634,6 +3637,9 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { // A new watch on the same pull request knows what the last one told. yield* watch(false, "stop"); yield* watch(true, "again"); + assert.isTrue( + (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.checksPending, + ); yield* restarted.sweep; yield* worker.drain(); assert.deepEqual(yield* summaries, [firstSummary]); diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 39731cb3d35b6..91a25b1784f25 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -320,7 +320,8 @@ review is terminal. An open or unsynced link keeps it active. Ask the agent to watch, monitor, or babysit a pull request and it calls `watch_pull_request`. While the thread is active, the server checks the pull request every two minutes and wakes the agent when a check fails or waits for a maintainer's approval, the required checks pass, someone else comments or reviews, or the branch starts to -conflict. Threads in a project that watch the same pull request share one check. On GitHub, a check +conflict. It also wakes when the remaining checks finish after an earlier failure. +Threads in a project that watch the same pull request share one check. On GitHub, a check first asks whether anything changed and reads the pull request only when it did, which keeps watching inside GitHub's rate limit. Comments from your own account do not wake it. Watching ends when the pull request merges or closes, after 10 wakes in a row that bring only comments, after 8 diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index bd0a65a3c0bc9..dfa94e1ea6fc1 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -104,6 +104,7 @@ export const ThreadPullRequestWatch = Schema.Struct({ * none did. Null once either happened; absent on watches saved before it existed. */ checksAwaitedSince: Schema.optional(Schema.NullOr(IsoDateTime)), + checksPending: Schema.optional(Schema.Boolean), }); export type ThreadPullRequestWatch = typeof ThreadPullRequestWatch.Type;