From 0fe122fdbe82a22eefbe1583ff9a854016433b66 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 24 Oct 2026 23:10:40 +0000 Subject: [PATCH 01/11] fix(server): PR watch waits once for workflow approval instead of flapping A fork PR whose workflows await maintainer approval woke the watching thread over and over, alternating "Checks failed (action-required)" with "All N checks passed": action-required counted as a failure, and the separate GitHub read that lists runs awaiting approval can answer empty, leaving only the bot checks to pass. action-required is now a wait: one "checks await approval" wake, and nothing counts as passed (required-only gate included) while any is awaited. A workflow run stays awaited, by its run URL, through reads that miss it until one of its jobs finishes; a head push clears it. The URL-less "could not tell" placeholder leaves the gate as it was instead of blocking forever. Watches saved before the new field that already reported these as failed are not told again. Fixes #24575 Co-Authored-By: Claude Opus 5.5 --- .../src/mcp/toolkits/pullRequests/tools.ts | 2 +- .../src/orchestration-v2/Orchestrator.ts | 3 + .../PullRequestWatchReactor.ts | 1 + .../orchestration-v2/pullRequestWatch.test.ts | 152 ++++++++++++++++++ .../src/orchestration-v2/pullRequestWatch.ts | 59 ++++++- .../src/orchestration-v2/runtimeLayer.test.ts | 120 ++++++++++++++ docs/user/source-control.md | 2 +- packages/contracts/src/threadPullRequest.ts | 5 + 8 files changed, 337 insertions(+), 7 deletions(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 4e476863700b0..ad8804be47d08 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -326,7 +326,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { const WatchPullRequestTool = Tool.make("watch_pull_request", { description: - "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every two minutes and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. While T3 Code watches, the thread stays in the user's Working list, not their inbox. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. Watching ends when the pull request merges or closes, when its thread settles or is archived, when T3 Code fails to read it 8 times in a row (a host rate limit only delays it), when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch. A subagent cannot watch: its parent thread owns the pull request.", + "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every two minutes and wakes you with a message 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 with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. While T3 Code watches, the thread stays in the user's Working list, not their inbox. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. Watching ends when the pull request merges or closes, when its thread settles or is archived, when T3 Code fails to read it 8 times in a row (a host rate limit only delays it), when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch. A subagent cannot watch: its parent thread owns the pull request.", parameters: PullRequestTargetInput, success: WatchPullRequestResult, failure: PullRequestToolError, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 48972ce4cd445..8e7de94f73114 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3440,6 +3440,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(existing.endedWatch?.owedChecks !== undefined && { owedChecks: existing.endedWatch.owedChecks, }), + ...(existing.endedWatch?.awaitingApproval !== undefined && { + awaitingApproval: existing.endedWatch.awaitingApproval, + }), ...(existing.endedWatch?.checksAwaitedSince !== undefined && { checksAwaitedSince: existing.endedWatch.checksAwaitedSince, }), diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 37631ce41a07d..68d5b001f5ba6 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -172,6 +172,7 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc left.passed === right.passed && left.passedChecks.join("\n") === right.passedChecks.join("\n") && (left.owedChecks ?? []).join("\n") === (right.owedChecks ?? []).join("\n") && + (left.awaitingApproval ?? []).join("\n") === (right.awaitingApproval ?? []).join("\n") && left.remarksThrough === right.remarksThrough && left.remarkIds.join("\n") === right.remarkIds.join("\n") && left.conflicting === right.conflicting && diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 0b6070f364662..52243d8512804 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -8,6 +8,7 @@ import { assert, describe, it } from "@effect/vitest"; import { PULL_REQUEST_WATCH_WAKE_LIMIT, evaluatePullRequestWatch as evaluate, + pullRequestWatchMessage, } from "./pullRequestWatch.ts"; // These cases read the head once or at one moment, so the no-checks notice never comes due. @@ -30,6 +31,8 @@ const watch = (overrides: Partial = {}): ThreadPullReque ...overrides, }); +const run = (id: number) => `https://github.com/o/r/actions/runs/${id}`; + const check = (name: string, status: PullRequestCheck["status"]): PullRequestCheck => ({ name, status, @@ -78,6 +81,155 @@ const remark = ( const noRemarks: ReadonlyArray = []; describe("evaluatePullRequestWatch", () => { + // regression: #24575 fork workflows awaiting approval flapped between failed and passed + it("waits for workflow approval once, through reads that miss it, until the run finishes", () => { + const bot = check("bot", "success"); + const approval = { ...check("CI", "action-required"), url: run(1) }; + const gated = detail({ checks: [bot, approval] }); + const first = evaluatePullRequestWatch(watch(), gated, noRemarks); + assert.deepEqual(first.changes, [{ kind: "checks-awaiting-approval", checks: [approval] }]); + assert.deepEqual(first.next.failedChecks, []); + assert.isFalse(first.next.passed); + const message = pullRequestWatchMessage({ + number: 1, + url: "https://github.com/o/r/pull/1", + baseBranch: "main", + headSha: "aaaaaaaaaa", + report: first, + }); + assert.equal(message.notification.outcome, "updated"); + assert.match(message.text, /approval/i); + + // GitHub's approval read can answer empty while the rest of the checks read fine. + const missing = evaluatePullRequestWatch(first.next, detail({ checks: [bot] }), noRemarks); + assert.deepEqual(missing.changes, []); + assert.isFalse(missing.next.passed); + const returned = evaluatePullRequestWatch(missing.next, gated, noRemarks); + assert.deepEqual(returned.changes, []); + + // Approved, the run's jobs carry their own names under the run's URL. + const job = (status: PullRequestCheck["status"]) => ({ + ...check("lint", status), + url: `${run(1)}/job/7`, + }); + const running = evaluatePullRequestWatch( + returned.next, + detail({ checks: [bot, job("pending")] }), + noRemarks, + ); + assert.deepEqual(running.changes, []); + // A read that misses the running job is not a pass either. + const lost = evaluatePullRequestWatch(running.next, detail({ checks: [bot] }), noRemarks); + assert.deepEqual(lost.changes, []); + assert.isFalse(lost.next.passed); + const completed = evaluatePullRequestWatch( + lost.next, + detail({ checks: [bot, job("success")] }), + noRemarks, + ); + assert.deepEqual(completed.changes, [{ kind: "checks-passed", count: 2, required: false }]); + assert.deepEqual(completed.next.awaitingApproval, []); + assert.deepEqual( + evaluatePullRequestWatch(completed.next, detail({ checks: [bot] }), noRemarks).changes, + [], + ); + }); + + it("does not report required checks passed while workflows await approval", () => { + const label = { ...check("Label", "success"), required: true }; + const approval = { ...check("CI", "action-required"), url: run(1) }; + const first = evaluatePullRequestWatch( + watch(), + detail({ checks: [label, approval] }), + noRemarks, + ); + assert.deepEqual(first.changes, [{ kind: "checks-awaiting-approval", checks: [approval] }]); + const missing = evaluatePullRequestWatch(first.next, detail({ checks: [label] }), noRemarks); + assert.deepEqual(missing.changes, []); + const test = { ...check("test", "success"), url: `${run(1)}/job/8`, required: true }; + const done = evaluatePullRequestWatch( + missing.next, + detail({ checks: [label, test] }), + noRemarks, + ); + assert.deepEqual(done.changes, [{ kind: "checks-passed", count: 2, required: true }]); + }); + + it("still reports failures beside workflows awaiting approval", () => { + const approval = { ...check("CI", "action-required"), url: run(1) }; + const broken = [check("lint", "failure"), check("e2e", "cancelled")]; + const first = evaluatePullRequestWatch( + watch(), + detail({ checks: [approval, ...broken] }), + noRemarks, + ); + assert.deepEqual(first.changes, [ + { kind: "checks-failed", failed: broken }, + { kind: "checks-awaiting-approval", checks: [approval] }, + ]); + const message = pullRequestWatchMessage({ + number: 1, + url: "https://github.com/o/r/pull/1", + baseBranch: "main", + headSha: "aaaaaaaaaa", + report: first, + }); + assert.equal(message.notification.outcome, "failed"); + assert.deepEqual( + evaluatePullRequestWatch(first.next, detail({ checks: broken }), noRemarks).changes, + [], + ); + }); + + it("waits for workflow approval again only on a new head", () => { + const bot = check("bot", "success"); + const approval = { ...check("CI", "action-required"), url: run(1) }; + const first = evaluatePullRequestWatch(watch(), detail({ checks: [bot, approval] }), noRemarks); + const pushed = evaluatePullRequestWatch( + first.next, + detail({ headSha: "bbbbbbbbbb", checks: [bot] }), + noRemarks, + ); + assert.deepEqual(pushed.changes, [{ kind: "checks-passed", count: 1, required: false }]); + assert.deepEqual(pushed.next.awaitingApproval, []); + const gatedAgain = { ...approval, url: run(2) }; + const second = evaluatePullRequestWatch( + pushed.next, + detail({ headSha: "bbbbbbbbbb", checks: [bot, gatedAgain] }), + noRemarks, + ); + assert.deepEqual(second.changes, [{ kind: "checks-awaiting-approval", checks: [gatedAgain] }]); + }); + + it("keeps the last state when a host cannot tell whether workflows await approval", () => { + const bot = check("bot", "success"); + const unknown = { ...check("Workflow approval status", "action-required"), url: null }; + const unread = evaluatePullRequestWatch(watch(), detail({ checks: [bot, unknown] }), noRemarks); + assert.deepEqual(unread.changes, []); + assert.isFalse(unread.next.passed); + const read = evaluatePullRequestWatch(unread.next, detail({ checks: [bot] }), noRemarks); + assert.deepEqual(read.changes, [{ kind: "checks-passed", count: 1, required: false }]); + const again = evaluatePullRequestWatch( + read.next, + detail({ checks: [bot, unknown] }), + noRemarks, + ); + assert.deepEqual(again.changes, []); + assert.isTrue(again.next.passed); + assert.deepEqual( + evaluatePullRequestWatch(again.next, detail({ checks: [bot] }), noRemarks).changes, + [], + ); + }); + + it("does not tell a watch saved before approvals were recorded about one it reported", () => { + const approval = { ...check("CI", "action-required"), url: run(1) }; + const saved = watch({ headSha: "aaaaaaaaaa", failedChecks: ["CI"] }); + const report = evaluatePullRequestWatch(saved, detail({ checks: [approval] }), noRemarks); + assert.deepEqual(report.changes, []); + assert.deepEqual(report.next.awaitingApproval, [run(1)]); + }); + it("reports each failure at once, even while another check never finishes", () => { const bot = check("CodeRabbit", "pending"); const first = detail({ checks: [check("lint", "failure"), check("test", "pending"), bot] }); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 0701354ee2c6b..000a7065d3e67 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -20,6 +20,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-awaiting-approval"; readonly checks: ReadonlyArray } | { readonly kind: "remarks"; readonly remarks: ReadonlyArray } | { readonly kind: "no-checks"; readonly minutes: number } | { readonly kind: "conflicting" }; @@ -30,9 +31,12 @@ export interface PullRequestWatchReport { readonly exhausted: boolean; } -// "action-required" is a finished check that needs someone, so the agent hears about it. const isFailedCheck = (check: PullRequestCheck) => - check.status === "failure" || check.status === "cancelled" || check.status === "action-required"; + check.status === "failure" || check.status === "cancelled"; + +// A GitHub Actions job's URL extends its workflow run's URL; their names need not match. +const workflowRunOf = (url: string | null) => url?.match(/^.*\/actions\/runs\/\d+/)?.[0] ?? null; +const approvalKey = (check: PullRequestCheck) => workflowRunOf(check.url) ?? check.name; export function evaluatePullRequestWatch( watch: ThreadPullRequestWatch, @@ -52,15 +56,51 @@ export function evaluatePullRequestWatch( let passed = headMoved ? false : watch.passed; let passedChecks = headMoved ? [] : watch.passedChecks; let owedChecks = headMoved ? [] : (watch.owedChecks ?? []); + let awaitingApproval = headMoved ? [] : (watch.awaitingApproval ?? []); + // "action-required" waits on someone, such as a maintainer approving a fork's workflows, so the + // agent is told once and nothing passes meanwhile. One with no URL is a host that could not + // tell, so that pass leaves the gate as it was. + const failed = detail.checks.filter(isFailedCheck); + const awaiting = detail.checks.filter( + (check) => check.status === "action-required" && check.url !== null, + ); + const unread = detail.checks.some( + (check) => check.status === "action-required" && check.url === null, + ); if (detail.checks.length > 0) { - 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 }); + // A watch saved before awaitingApproval reported these as failed checks. + const toldBefore = + awaitingApproval.length > 0 || + (watch.awaitingApproval === undefined && + awaiting.every((check) => failedChecks.includes(check.name))); + if (awaiting.length > 0 && !toldBefore) { + changes.push({ kind: "checks-awaiting-approval", checks: awaiting }); + } failedChecks = failed.map((check) => check.name); + // GitHub lists runs awaiting approval in a separate read that can come back empty, so a run + // stays awaited through reads that miss it until one of its jobs finishes. + const awaitingNow = [...new Set(awaiting.map(approvalKey))]; + const finished = new Set( + detail.checks + .filter((check) => check.status !== "action-required" && check.status !== "pending") + .map((check) => workflowRunOf(check.url)), + ); + awaitingApproval = [ + ...awaitingNow, + ...awaitingApproval.filter( + (key) => !awaitingNow.includes(key) && workflowRunOf(key) === key && !finished.has(key), + ), + ]; + } + if (detail.checks.length > 0 && !unread) { const required = detail.checks.filter((check) => check.required === true); const gate = required.length > 0 ? required : detail.checks; - const passedNow = gate.every((check) => check.status !== "pending" && !isFailedCheck(check)); + const passedNow = + awaitingApproval.length === 0 && + gate.every((check) => check.status !== "pending" && !isFailedCheck(check)); const gateNames = gate.map((check) => check.name); const told = passed && passedChecks.length === 0 ? gateNames : passedChecks; // A required job created and finished between two passes is never seen pending. Without @@ -75,7 +115,9 @@ export function evaluatePullRequestWatch( .map((check) => check.name) : []; const settled = - failed.length === 0 && detail.checks.every((check) => check.status !== "pending"); + failed.length === 0 && + awaitingApproval.length === 0 && + detail.checks.every((check) => check.status !== "pending"); owedChecks = owed; if (passedNow && (!passed || gateGrew)) { changes.push({ kind: "checks-passed", count: gate.length, required: required.length > 0 }); @@ -149,6 +191,7 @@ export function evaluatePullRequestWatch( passed, passedChecks, owedChecks, + awaitingApproval, remarksThrough, remarkIds, conflicting, @@ -187,6 +230,11 @@ function changeLines( ` - ${check.name}${check.status === "failure" ? "" : ` (${check.status})`}${check.url ? ` ${check.url}` : ""}`, ), ]; + case "checks-awaiting-approval": + return [ + `- Checks are waiting for approval or action from a maintainer${context.commit}:`, + ...listed(change.checks, (check) => ` - ${check.name}${check.url ? ` ${check.url}` : ""}`), + ]; case "checks-passed": return [ `- All ${change.count} ${change.required ? "required " : ""}${change.count === 1 ? "check" : "checks"} passed${context.commit}.`, @@ -213,6 +261,7 @@ function changeLines( const SUMMARY: Record = { "checks-failed": "checks failed", "checks-passed": "checks passed", + "checks-awaiting-approval": "checks await approval", remarks: "new comments", "no-checks": "no checks reported", conflicting: "merge conflict", diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 9f0edb83f0f3c..7481c6c38859e 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -19,6 +19,7 @@ import { type ModelSelection, type OrchestrationV2Run, ProjectId, + type PullRequestCheck, type PullRequestDetail, type PullRequestComment, PullRequestOperationError, @@ -2744,6 +2745,125 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + // regression: #24575 fork workflows awaiting approval flapped between failed and passed + it.effect("keeps awaited workflow approvals across empty reads, a restart, and a re-watch", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make("runtime-pull-request-watch-approval"); + const projectId = ProjectId.make("pr-watch-approval-project"); + yield* seedProject({ + projectId, + title: "Watch approval", + workspaceRoot: "/workspace/watch-approval", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-approval-create"), + threadId, + projectId, + title: "Watch approval", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const watch = (on: boolean, id: string) => + orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-approval-${id}`), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 12, + watching: on, + link: { url: "https://github.com/pingdotgg/t3code/pull/12", source: "agent" }, + }); + yield* watch(true, "start"); + + const run = (id: number) => `https://github.com/pingdotgg/t3code/actions/runs/${id}`; + const bot = { name: "bot", status: "success" as const, description: null, url: null }; + const approval = (id: number) => ({ + name: `Workflow ${id}`, + status: "action-required" as const, + description: null, + url: run(id), + }); + const job = (id: number) => ({ + name: `job ${id}`, + status: "success" as const, + description: null, + url: `${run(id)}/job/${id}0`, + }); + let checks: ReadonlyArray = [bot, approval(1), approval(2)]; + const host = Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => + Effect.sync(() => ({ + ...watchedPullRequestDetail({ + projectId, + number: 12, + at: "2026-10-02T12:00:00.000Z", + }), + headSha: "ad70160aaa", + checks, + })), + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ); + const summaries = Effect.map( + orchestrator.getThreadRecords(threadId, ["messages"]), + ({ messages }) => messages.flatMap((message) => message.notification?.summary ?? []), + ); + const awaited = Effect.map( + orchestrator.getThreadShell(threadId), + (thread) => thread?.pullRequests?.[0]?.watch?.awaitingApproval, + ); + const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + const pass = (target: typeof reactor) => + Effect.gen(function* () { + yield* TestClock.adjust("10 minutes"); + yield* target.sweep; + yield* worker.drain(); + }); + yield* pass(reactor); + assert.deepEqual(yield* summaries, ["#12: checks await approval"]); + + // One run finishing changes only what is awaited, which is still saved without a wake. + checks = [bot, approval(1), job(2)]; + yield* pass(reactor); + assert.deepEqual(yield* awaited, [run(1)]); + + // The approval read answering empty, after a restart and a re-watch, is still not a pass. + checks = [bot, job(2)]; + const restarted = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + yield* pass(restarted); + yield* watch(false, "stop"); + yield* watch(true, "again"); + yield* pass(restarted); + assert.deepEqual(yield* summaries, ["#12: checks await approval"]); + assert.deepEqual(yield* awaited, [run(1)]); + + checks = [bot, job(1), job(2)]; + yield* pass(restarted); + assert.deepEqual(yield* summaries, ["#12: checks await approval", "#12: checks passed"]); + yield* watch(false, "end"); + }), + ); + // regression: #21155 watching a pull request again repeated news the thread already got it.effect("a watch that ends on the comment limit keeps what its last pass saw", () => Effect.gen(function* () { diff --git a/docs/user/source-control.md b/docs/user/source-control.md index de63202c51ebd..f25714b26d812 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -285,7 +285,7 @@ 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, the required checks pass, someone else comments or reviews, or the branch starts to +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 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 diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index ecaa4d889dee4..56d40edad8437 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -91,6 +91,11 @@ export const ThreadPullRequestWatch = Schema.Struct({ ), /** Checks still pending when required ones passed early, owed one wake once all settle. */ owedChecks: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + /** + * Checks waiting for someone to approve or act on them, the agent told once: a workflow run's + * URL, kept until the run is seen going even through reads that miss it, or a check's name. + */ + awaitingApproval: Schema.optional(Schema.Array(TrimmedNonEmptyString)), remarksThrough: IsoDateTime, /** Remarks created exactly at `remarksThrough` that were reported, so a late one still counts. */ remarkIds: Schema.Array(TrimmedNonEmptyString), From 873a9bce88949a9337c7f16762455ef32159356a Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 24 Oct 2026 23:19:51 +0000 Subject: [PATCH 02/11] fix(server): PR watch tells real URL-less approvals from unread ones and keeps its head through headless reads The watch took any action-required check without a URL for GitHub's "could not tell whether workflows await approval" stand-in, so a real check awaiting action that carried no URL never woke the agent and let the bot checks pass. The provider now marks that stand-in with a placeholder flag on the check, and only it leaves the gate as it was; a URL-less action-required check waits like any other, held while it is listed. A read that named no head counted as a push, so it cleared remembered approvals and the next read of the same head could report the bot checks passed. The watch now keeps the head it knew through such a read and resets only when a different head arrives. Refs #24575 Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/pullRequestWatch.test.ts | 59 ++++++++++++++++++- .../src/orchestration-v2/pullRequestWatch.ts | 13 ++-- packages/contracts/src/pullRequest.ts | 5 ++ packages/contracts/src/threadPullRequest.ts | 3 +- .../server/GitHubPullRequestProvider.test.ts | 2 + .../src/server/GitHubPullRequestProvider.ts | 1 + 6 files changed, 74 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 52243d8512804..1c56dac4e0b8c 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -203,7 +203,11 @@ describe("evaluatePullRequestWatch", () => { it("keeps the last state when a host cannot tell whether workflows await approval", () => { const bot = check("bot", "success"); - const unknown = { ...check("Workflow approval status", "action-required"), url: null }; + const unknown = { + ...check("Workflow approval status", "action-required"), + url: null, + placeholder: true, + }; const unread = evaluatePullRequestWatch(watch(), detail({ checks: [bot, unknown] }), noRemarks); assert.deepEqual(unread.changes, []); assert.isFalse(unread.next.passed); @@ -222,6 +226,59 @@ describe("evaluatePullRequestWatch", () => { ); }); + it("waits for an approval with no URL while the host lists it", () => { + const bot = check("bot", "success"); + const green = evaluatePullRequestWatch(watch(), detail({ checks: [bot] }), noRemarks); + assert.isTrue(green.next.passed); + const approval = { ...check("contributor tests", "action-required"), url: null }; + const gated = evaluatePullRequestWatch( + green.next, + detail({ checks: [bot, approval] }), + noRemarks, + ); + assert.deepEqual(gated.changes, [{ kind: "checks-awaiting-approval", checks: [approval] }]); + assert.isFalse(gated.next.passed); + const still = evaluatePullRequestWatch( + gated.next, + detail({ checks: [bot, approval] }), + noRemarks, + ); + assert.deepEqual(still.changes, []); + // Nothing else names it, so it is held only while listed, unlike a workflow run. + const done = evaluatePullRequestWatch( + still.next, + detail({ checks: [bot, check("contributor tests", "success")] }), + noRemarks, + ); + assert.deepEqual(done.changes, [{ kind: "checks-passed", count: 2, required: false }]); + assert.deepEqual(done.next.awaitingApproval, []); + }); + + it("keeps the head and awaited approvals through a read that names no head", () => { + const bot = check("bot", "success"); + const approval = { ...check("CI", "action-required"), url: run(1) }; + const unknown = { + ...check("Workflow approval status", "action-required"), + url: null, + placeholder: true, + }; + const first = evaluatePullRequestWatch(watch(), detail({ checks: [bot, approval] }), noRemarks); + const { headSha: _headSha, ...headless } = detail({ checks: [bot, unknown] }); + const blind = evaluatePullRequestWatch(first.next, headless, noRemarks); + assert.deepEqual(blind.changes, []); + assert.equal(blind.next.headSha, "aaaaaaaaaa"); + assert.deepEqual(blind.next.awaitingApproval, [run(1)]); + const same = evaluatePullRequestWatch(blind.next, detail({ checks: [bot] }), noRemarks); + assert.deepEqual(same.changes, []); + assert.isFalse(same.next.passed); + const pushed = evaluatePullRequestWatch( + same.next, + detail({ headSha: "bbbbbbbbbb", checks: [bot] }), + noRemarks, + ); + assert.deepEqual(pushed.changes, [{ kind: "checks-passed", count: 1, required: false }]); + }); + it("does not tell a watch saved before approvals were recorded about one it reported", () => { const approval = { ...check("CI", "action-required"), url: run(1) }; const saved = watch({ headSha: "aaaaaaaaaa", failedChecks: ["CI"] }); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 000a7065d3e67..b54efa4b4dc9b 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -48,7 +48,8 @@ export function evaluatePullRequestWatch( now: number, ): PullRequestWatchReport { const changes: Array = []; - const headSha = detail.headSha ?? null; + // A read that names no head is not a push: the watch keeps the head it knew. + const headSha = detail.headSha ?? watch.headSha; const headMoved = headSha !== watch.headSha; // An empty list keeps the last state: a host can answer with one when its check read fails. @@ -58,15 +59,13 @@ export function evaluatePullRequestWatch( let owedChecks = headMoved ? [] : (watch.owedChecks ?? []); let awaitingApproval = headMoved ? [] : (watch.awaitingApproval ?? []); // "action-required" waits on someone, such as a maintainer approving a fork's workflows, so the - // agent is told once and nothing passes meanwhile. One with no URL is a host that could not - // tell, so that pass leaves the gate as it was. + // agent is told once and nothing passes meanwhile. A placeholder is a host that could not tell, + // so that pass leaves the gate as it was. const failed = detail.checks.filter(isFailedCheck); const awaiting = detail.checks.filter( - (check) => check.status === "action-required" && check.url !== null, - ); - const unread = detail.checks.some( - (check) => check.status === "action-required" && check.url === null, + (check) => check.status === "action-required" && check.placeholder !== true, ); + const unread = detail.checks.some((check) => check.placeholder === true); if (detail.checks.length > 0) { const newlyFailed = failed.filter((check) => !failedChecks.includes(check.name)); if (newlyFailed.length > 0) changes.push({ kind: "checks-failed", failed: newlyFailed }); diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index e7bfa2c35e56a..e4c968f3c23f8 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -113,6 +113,11 @@ export const PullRequestCheck = Schema.Struct({ description: Schema.NullOr(Schema.String), url: Schema.NullOr(Schema.String), required: Schema.optional(Schema.Boolean), + /** + * Not a check: it stands for ones the host could not list, such as workflow runs awaiting + * approval when that read fails, so nothing waits on it or counts it as passing. + */ + placeholder: Schema.optional(Schema.Boolean), }); export type PullRequestCheck = typeof PullRequestCheck.Type; diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index 56d40edad8437..3bee6ed2868d1 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -93,7 +93,8 @@ export const ThreadPullRequestWatch = Schema.Struct({ owedChecks: Schema.optional(Schema.Array(TrimmedNonEmptyString)), /** * Checks waiting for someone to approve or act on them, the agent told once: a workflow run's - * URL, kept until the run is seen going even through reads that miss it, or a check's name. + * URL, kept through reads that miss it until one of its jobs finishes, or else a check's name, + * kept only while a read lists it. */ awaitingApproval: Schema.optional(Schema.Array(TrimmedNonEmptyString)), remarksThrough: IsoDateTime, diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts index 1263f07f753c5..d1f98e74d21ee 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts @@ -218,6 +218,7 @@ describe("gitHubViewerPermissions", () => { status: "action-required", description: "GitHub could not determine whether workflows are awaiting approval.", url: null, + placeholder: true, }); for (const fingerprint of ["broad", "restricted", "broad"]) { const scoped = yield* provider @@ -481,6 +482,7 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () => status: "action-required", description: "GitHub could not determine whether workflows are awaiting approval.", url: null, + placeholder: true, }, ]); }).pipe( diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index 17bf91e83777c..4a21e0cd42fbe 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -176,6 +176,7 @@ function withWorkflowApprovals( status: "action-required" as const, description: "GitHub could not determine whether workflows are awaiting approval.", url: null, + placeholder: true, }, ] : []), From 5f30a39eafb2272230ad4bd22b00454714c47e7c Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 24 Oct 2026 23:41:51 +0000 Subject: [PATCH 03/11] fix(server): PR watch ends a pass on known bad checks beside unread approvals A read where GitHub could not list workflows awaiting approval skipped the whole gate update, so a required failure, a pending rerun, or a listed approval left the watch marked passed and the recovery read never woke the agent. The unread marker now only stops a read from confirming a pass. Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/pullRequestWatch.test.ts | 61 +++++++++++++++++++ .../src/orchestration-v2/pullRequestWatch.ts | 14 ++--- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 1c56dac4e0b8c..269f4ee3b891c 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -226,6 +226,67 @@ describe("evaluatePullRequestWatch", () => { ); }); + it("ends a pass on a known failure, pending check, or approval beside an unread one", () => { + const unknown = { + ...check("Workflow approval status", "action-required"), + url: null, + placeholder: true, + }; + const required = (status: PullRequestCheck["status"]) => ({ + ...check("test", status), + required: true, + }); + const green = detail({ checks: [required("success"), check("e2e", "pending")] }); + const first = evaluatePullRequestWatch(watch(), green, noRemarks); + assert.deepEqual(first.next.owedChecks, ["e2e"]); + // Only good news beside the unread approvals keeps the state, owed checks included. + const blind = evaluatePullRequestWatch( + first.next, + detail({ checks: [...green.checks, unknown] }), + noRemarks, + ); + assert.deepEqual(blind.changes, []); + assert.deepEqual(blind.next.owedChecks, ["e2e"]); + + const broken = required("failure"); + const failed = evaluatePullRequestWatch( + blind.next, + detail({ checks: [broken, unknown] }), + noRemarks, + ); + assert.deepEqual(failed.changes, [{ kind: "checks-failed", failed: [broken] }]); + assert.isFalse(failed.next.passed); + const fixed = evaluatePullRequestWatch( + failed.next, + detail({ checks: [required("success")] }), + noRemarks, + ); + assert.deepEqual(fixed.changes, [{ kind: "checks-passed", count: 1, required: true }]); + + // Where nothing is required, a rerun pending beside the unread approvals ends the pass too. + const bot = check("bot", "success"); + const passed = evaluatePullRequestWatch(watch(), detail({ checks: [bot] }), noRemarks); + const rerun = evaluatePullRequestWatch( + passed.next, + detail({ checks: [check("bot", "pending"), unknown] }), + noRemarks, + ); + assert.deepEqual(rerun.changes, []); + assert.isFalse(rerun.next.passed); + const done = evaluatePullRequestWatch(rerun.next, detail({ checks: [bot] }), noRemarks); + assert.deepEqual(done.changes, [{ kind: "checks-passed", count: 1, required: false }]); + + // So does a run listed as awaiting approval while others could not be read. + const approval = { ...check("CI", "action-required"), url: run(1) }; + const gated = evaluatePullRequestWatch( + passed.next, + detail({ checks: [bot, approval, unknown] }), + noRemarks, + ); + assert.deepEqual(gated.changes, [{ kind: "checks-awaiting-approval", checks: [approval] }]); + assert.isFalse(gated.next.passed); + }); + it("waits for an approval with no URL while the host lists it", () => { const bot = check("bot", "success"); const green = evaluatePullRequestWatch(watch(), detail({ checks: [bot] }), noRemarks); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index b54efa4b4dc9b..da95406c9ca7b 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -60,7 +60,7 @@ export function evaluatePullRequestWatch( let awaitingApproval = headMoved ? [] : (watch.awaitingApproval ?? []); // "action-required" waits on someone, such as a maintainer approving a fork's workflows, so the // agent is told once and nothing passes meanwhile. A placeholder is a host that could not tell, - // so that pass leaves the gate as it was. + // so it never confirms a pass, though a failed, pending, or awaited check beside it ends one. const failed = detail.checks.filter(isFailedCheck); const awaiting = detail.checks.filter( (check) => check.status === "action-required" && check.placeholder !== true, @@ -94,12 +94,12 @@ export function evaluatePullRequestWatch( ), ]; } - if (detail.checks.length > 0 && !unread) { - const required = detail.checks.filter((check) => check.required === true); - const gate = required.length > 0 ? required : detail.checks; - const passedNow = - awaitingApproval.length === 0 && - gate.every((check) => check.status !== "pending" && !isFailedCheck(check)); + const required = detail.checks.filter((check) => check.required === true); + const gate = required.length > 0 ? required : detail.checks; + const passedNow = + awaitingApproval.length === 0 && + gate.every((check) => check.status !== "pending" && !isFailedCheck(check)); + if (detail.checks.length > 0 && !(unread && passedNow)) { const gateNames = gate.map((check) => check.name); const told = passed && passedChecks.length === 0 ? gateNames : passedChecks; // A required job created and finished between two passes is never seen pending. Without From 43044912661266bfae7e59aa667040b7b539d35b Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 24 Oct 2026 23:47:50 +0000 Subject: [PATCH 04/11] fix(server): PR watch treats a read with only an unread approval as no checks GitHub's unread approval marker counted as a seen check, so a read that listed only it wiped the failed checks already told (the next failure woke the agent again), dropped approvals waited on by name, and stopped the no-checks clock. The watch now counts only real checks, so such a read keeps its state like an empty one. Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/pullRequestWatch.test.ts | 42 +++++++++++++++++++ .../src/orchestration-v2/pullRequestWatch.ts | 30 ++++++------- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 269f4ee3b891c..94675ef244d2f 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -287,6 +287,39 @@ describe("evaluatePullRequestWatch", () => { assert.isFalse(gated.next.passed); }); + it("keeps failures and approvals through a read that lists only an unread approval", () => { + const unknown = { + ...check("Workflow approval status", "action-required"), + url: null, + placeholder: true, + }; + const broken = check("CI", "failure"); + const approval = { ...check("contributor tests", "action-required"), url: null }; + const seen = evaluatePullRequestWatch( + watch(), + detail({ checks: [broken, approval] }), + noRemarks, + ); + assert.deepEqual(seen.changes, [ + { kind: "checks-failed", failed: [broken] }, + { kind: "checks-awaiting-approval", checks: [approval] }, + ]); + const blind = evaluatePullRequestWatch(seen.next, detail({ checks: [unknown] }), noRemarks); + assert.deepEqual(blind.changes, []); + assert.deepEqual(blind.next, seen.next); + assert.deepEqual( + evaluatePullRequestWatch(blind.next, detail({ checks: [broken, approval] }), noRemarks) + .changes, + [], + ); + const pushed = evaluatePullRequestWatch( + blind.next, + detail({ headSha: "bbbbbbbbbb", checks: [unknown] }), + noRemarks, + ); + assert.deepEqual([pushed.next.failedChecks, pushed.next.awaitingApproval], [[], []]); + }); + it("waits for an approval with no URL while the host lists it", () => { const bot = check("bot", "success"); const green = evaluatePullRequestWatch(watch(), detail({ checks: [bot] }), noRemarks); @@ -666,6 +699,15 @@ describe("evaluatePullRequestWatch", () => { assert.deepEqual(pushed.flat(), ["no-checks"]); }); + it("waits for checks through reads that list only an unread approval", () => { + const unknown = { + ...check("Workflow approval status", "action-required"), + url: null, + placeholder: true, + }; + assert.deepEqual(anHourLater(watch(), detail({ checks: [unknown] })).flat(), ["no-checks"]); + }); + it("never reports missing checks on a host where an empty list says nothing", () => { const silent = detail({ capabilities: capabilities(false), checks: [] }); assert.isTrue(anHourLater(watch(), silent).every((kinds) => kinds.length === 0)); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index da95406c9ca7b..f84327c2eb854 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -60,13 +60,13 @@ export function evaluatePullRequestWatch( let awaitingApproval = headMoved ? [] : (watch.awaitingApproval ?? []); // "action-required" waits on someone, such as a maintainer approving a fork's workflows, so the // agent is told once and nothing passes meanwhile. A placeholder is a host that could not tell, - // so it never confirms a pass, though a failed, pending, or awaited check beside it ends one. - const failed = detail.checks.filter(isFailedCheck); - const awaiting = detail.checks.filter( - (check) => check.status === "action-required" && check.placeholder !== true, - ); + // so it counts as no check and never confirms a pass, though a failed, pending, or awaited check + // beside it ends one. const unread = detail.checks.some((check) => check.placeholder === true); - if (detail.checks.length > 0) { + const checks = detail.checks.filter((check) => check.placeholder !== true); + const failed = checks.filter(isFailedCheck); + const awaiting = checks.filter((check) => check.status === "action-required"); + if (checks.length > 0) { const newlyFailed = failed.filter((check) => !failedChecks.includes(check.name)); if (newlyFailed.length > 0) changes.push({ kind: "checks-failed", failed: newlyFailed }); // A watch saved before awaitingApproval reported these as failed checks. @@ -83,7 +83,7 @@ export function evaluatePullRequestWatch( // stays awaited through reads that miss it until one of its jobs finishes. const awaitingNow = [...new Set(awaiting.map(approvalKey))]; const finished = new Set( - detail.checks + checks .filter((check) => check.status !== "action-required" && check.status !== "pending") .map((check) => workflowRunOf(check.url)), ); @@ -94,12 +94,12 @@ export function evaluatePullRequestWatch( ), ]; } - const required = detail.checks.filter((check) => check.required === true); - const gate = required.length > 0 ? required : detail.checks; + const required = checks.filter((check) => check.required === true); + const gate = required.length > 0 ? required : checks; const passedNow = awaitingApproval.length === 0 && gate.every((check) => check.status !== "pending" && !isFailedCheck(check)); - if (detail.checks.length > 0 && !(unread && passedNow)) { + if (checks.length > 0 && !(unread && passedNow)) { const gateNames = gate.map((check) => check.name); const told = passed && passedChecks.length === 0 ? gateNames : passedChecks; // A required job created and finished between two passes is never seen pending. Without @@ -109,25 +109,25 @@ export function evaluatePullRequestWatch( // Only those are owed, so later advisory churn never wakes. One turned required is gate news. const owed = passedNow && passed - ? detail.checks + ? checks .filter((check) => check.required !== true && owedChecks.includes(check.name)) .map((check) => check.name) : []; const settled = failed.length === 0 && awaitingApproval.length === 0 && - detail.checks.every((check) => check.status !== "pending"); + checks.every((check) => check.status !== "pending"); owedChecks = owed; if (passedNow && (!passed || gateGrew)) { changes.push({ kind: "checks-passed", count: gate.length, required: required.length > 0 }); owedChecks = required.length > 0 - ? detail.checks + ? checks .filter((check) => check.required !== true && check.status === "pending") .map((check) => check.name) : []; } else if (settled && owed.length > 0) { - changes.push({ kind: "checks-passed", count: detail.checks.length, required: false }); + changes.push({ kind: "checks-passed", count: checks.length, required: false }); owedChecks = []; } passed = passedNow; @@ -143,7 +143,7 @@ export function evaluatePullRequestWatch( const awaitedBefore = headMoved ? DateTime.formatIso(DateTime.makeUnsafe(now)) : (watch.checksAwaitedSince ?? null); - checksAwaitedSince = detail.checks.length > 0 ? null : awaitedBefore; + checksAwaitedSince = checks.length > 0 ? null : awaitedBefore; const awaitedMs = checksAwaitedSince === null ? 0 : now - Date.parse(checksAwaitedSince); if (awaitedMs >= PULL_REQUEST_WATCH_NO_CHECKS_MS) { changes.push({ kind: "no-checks", minutes: Math.round(awaitedMs / 60_000) }); From 52e57299f7510e8a0103a1580ad7765a1571619a Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 00:02:57 +0000 Subject: [PATCH 05/11] fix(server): PR watch keeps failures and approvals a partly unread read misses A read beside an unread-approval placeholder could list only an unrelated check, so it reset the known failures and dropped approvals awaited by name. When they came back, the agent was woken for them again. Such a read now lets go only of the checks it lists; complete reads and pushes still clear them. Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/pullRequestWatch.test.ts | 53 +++++++++++++++++++ .../src/orchestration-v2/pullRequestWatch.ts | 11 ++-- packages/contracts/src/threadPullRequest.ts | 2 +- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 94675ef244d2f..72674ad17dae3 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -320,6 +320,59 @@ describe("evaluatePullRequestWatch", () => { assert.deepEqual([pushed.next.failedChecks, pushed.next.awaitingApproval], [[], []]); }); + it("keeps failures and approvals an unread read misses while updating the checks it lists", () => { + const unknown = { + ...check("Workflow approval status", "action-required"), + url: null, + placeholder: true, + }; + const bot = check("bot", "success"); + const broken = check("CI", "failure"); + const approval = { ...check("contributor tests", "action-required"), url: null }; + const seen = evaluatePullRequestWatch( + watch(), + detail({ checks: [broken, approval] }), + noRemarks, + ); + const blind = evaluatePullRequestWatch( + seen.next, + detail({ checks: [bot, unknown] }), + noRemarks, + ); + assert.deepEqual(blind.changes, []); + assert.deepEqual( + [blind.next.failedChecks, blind.next.awaitingApproval], + [["CI"], ["contributor tests"]], + ); + assert.deepEqual( + evaluatePullRequestWatch(blind.next, detail({ checks: [broken, approval] }), noRemarks) + .changes, + [], + ); + + // What the read does list still counts: a new failure wakes, a settled check is let go. + const lint = check("lint", "failure"); + const listed = evaluatePullRequestWatch( + blind.next, + detail({ + checks: [check("CI", "pending"), check("contributor tests", "success"), lint, unknown], + }), + noRemarks, + ); + assert.deepEqual(listed.changes, [{ kind: "checks-failed", failed: [lint] }]); + assert.deepEqual([listed.next.failedChecks, listed.next.awaitingApproval], [["lint"], []]); + + // A read that could tell keeps nothing it does not list, and a push keeps nothing at all. + const whole = evaluatePullRequestWatch(blind.next, detail({ checks: [bot] }), noRemarks); + assert.deepEqual([whole.next.failedChecks, whole.next.awaitingApproval], [[], []]); + const pushed = evaluatePullRequestWatch( + blind.next, + detail({ headSha: "bbbbbbbbbb", checks: [bot, unknown] }), + noRemarks, + ); + assert.deepEqual([pushed.next.failedChecks, pushed.next.awaitingApproval], [[], []]); + }); + it("waits for an approval with no URL while the host lists it", () => { const bot = check("bot", "success"); const green = evaluatePullRequestWatch(watch(), detail({ checks: [bot] }), noRemarks); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index f84327c2eb854..e09400e011832 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -67,6 +67,8 @@ export function evaluatePullRequestWatch( const failed = checks.filter(isFailedCheck); const awaiting = checks.filter((check) => check.status === "action-required"); if (checks.length > 0) { + // A read beside a placeholder may have missed checks, so it lets go only of those it lists. + const missed = (name: string) => unread && !checks.some((check) => check.name === name); const newlyFailed = failed.filter((check) => !failedChecks.includes(check.name)); if (newlyFailed.length > 0) changes.push({ kind: "checks-failed", failed: newlyFailed }); // A watch saved before awaitingApproval reported these as failed checks. @@ -77,10 +79,11 @@ export function evaluatePullRequestWatch( if (awaiting.length > 0 && !toldBefore) { changes.push({ kind: "checks-awaiting-approval", checks: awaiting }); } - failedChecks = failed.map((check) => check.name); + failedChecks = [...failedChecks.filter(missed), ...failed.map((check) => check.name)]; // GitHub lists runs awaiting approval in a separate read that can come back empty, so a run - // stays awaited through reads that miss it until one of its jobs finishes. + // stays awaited through reads that miss it until one of its jobs finishes. One known only by + // name stays only while listed, or through a read beside a placeholder that does not list it. const awaitingNow = [...new Set(awaiting.map(approvalKey))]; const finished = new Set( checks @@ -90,7 +93,9 @@ export function evaluatePullRequestWatch( awaitingApproval = [ ...awaitingNow, ...awaitingApproval.filter( - (key) => !awaitingNow.includes(key) && workflowRunOf(key) === key && !finished.has(key), + (key) => + !awaitingNow.includes(key) && + (workflowRunOf(key) === key ? !finished.has(key) : missed(key)), ), ]; } diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index 3bee6ed2868d1..77eda3ca99efc 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -94,7 +94,7 @@ export const ThreadPullRequestWatch = Schema.Struct({ /** * Checks waiting for someone to approve or act on them, the agent told once: a workflow run's * URL, kept through reads that miss it until one of its jobs finishes, or else a check's name, - * kept only while a read lists it. + * kept while a read lists it or could not list everything. */ awaitingApproval: Schema.optional(Schema.Array(TrimmedNonEmptyString)), remarksThrough: IsoDateTime, From 94a34cd7f95aa019ae32facdde17b93f713af437 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 20:29:51 +0000 Subject: [PATCH 06/11] test(server): keep only the PR watch approval tests that guard the fix Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/pullRequestWatch.test.ts | 326 +----------------- .../src/orchestration-v2/runtimeLayer.test.ts | 120 ------- 2 files changed, 10 insertions(+), 436 deletions(-) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 72674ad17dae3..22840e8ff90f7 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -88,8 +88,6 @@ describe("evaluatePullRequestWatch", () => { const gated = detail({ checks: [bot, approval] }); const first = evaluatePullRequestWatch(watch(), gated, noRemarks); assert.deepEqual(first.changes, [{ kind: "checks-awaiting-approval", checks: [approval] }]); - assert.deepEqual(first.next.failedChecks, []); - assert.isFalse(first.next.passed); const message = pullRequestWatchMessage({ number: 1, url: "https://github.com/o/r/pull/1", @@ -98,110 +96,20 @@ describe("evaluatePullRequestWatch", () => { report: first, }); assert.equal(message.notification.outcome, "updated"); - assert.match(message.text, /approval/i); // GitHub's approval read can answer empty while the rest of the checks read fine. const missing = evaluatePullRequestWatch(first.next, detail({ checks: [bot] }), noRemarks); assert.deepEqual(missing.changes, []); assert.isFalse(missing.next.passed); - const returned = evaluatePullRequestWatch(missing.next, gated, noRemarks); - assert.deepEqual(returned.changes, []); + assert.deepEqual(evaluatePullRequestWatch(missing.next, gated, noRemarks).changes, []); // Approved, the run's jobs carry their own names under the run's URL. - const job = (status: PullRequestCheck["status"]) => ({ - ...check("lint", status), - url: `${run(1)}/job/7`, - }); - const running = evaluatePullRequestWatch( - returned.next, - detail({ checks: [bot, job("pending")] }), - noRemarks, - ); - assert.deepEqual(running.changes, []); - // A read that misses the running job is not a pass either. - const lost = evaluatePullRequestWatch(running.next, detail({ checks: [bot] }), noRemarks); - assert.deepEqual(lost.changes, []); - assert.isFalse(lost.next.passed); - const completed = evaluatePullRequestWatch( - lost.next, - detail({ checks: [bot, job("success")] }), - noRemarks, - ); - assert.deepEqual(completed.changes, [{ kind: "checks-passed", count: 2, required: false }]); - assert.deepEqual(completed.next.awaitingApproval, []); - assert.deepEqual( - evaluatePullRequestWatch(completed.next, detail({ checks: [bot] }), noRemarks).changes, - [], - ); - }); - - it("does not report required checks passed while workflows await approval", () => { - const label = { ...check("Label", "success"), required: true }; - const approval = { ...check("CI", "action-required"), url: run(1) }; - const first = evaluatePullRequestWatch( - watch(), - detail({ checks: [label, approval] }), - noRemarks, - ); - assert.deepEqual(first.changes, [{ kind: "checks-awaiting-approval", checks: [approval] }]); - const missing = evaluatePullRequestWatch(first.next, detail({ checks: [label] }), noRemarks); - assert.deepEqual(missing.changes, []); - const test = { ...check("test", "success"), url: `${run(1)}/job/8`, required: true }; - const done = evaluatePullRequestWatch( - missing.next, - detail({ checks: [label, test] }), - noRemarks, - ); - assert.deepEqual(done.changes, [{ kind: "checks-passed", count: 2, required: true }]); - }); - - it("still reports failures beside workflows awaiting approval", () => { - const approval = { ...check("CI", "action-required"), url: run(1) }; - const broken = [check("lint", "failure"), check("e2e", "cancelled")]; - const first = evaluatePullRequestWatch( - watch(), - detail({ checks: [approval, ...broken] }), - noRemarks, - ); - assert.deepEqual(first.changes, [ - { kind: "checks-failed", failed: broken }, - { kind: "checks-awaiting-approval", checks: [approval] }, - ]); - const message = pullRequestWatchMessage({ - number: 1, - url: "https://github.com/o/r/pull/1", - baseBranch: "main", - headSha: "aaaaaaaaaa", - report: first, - }); - assert.equal(message.notification.outcome, "failed"); - assert.deepEqual( - evaluatePullRequestWatch(first.next, detail({ checks: broken }), noRemarks).changes, - [], - ); - }); - - it("waits for workflow approval again only on a new head", () => { - const bot = check("bot", "success"); - const approval = { ...check("CI", "action-required"), url: run(1) }; - const first = evaluatePullRequestWatch(watch(), detail({ checks: [bot, approval] }), noRemarks); - const pushed = evaluatePullRequestWatch( - first.next, - detail({ headSha: "bbbbbbbbbb", checks: [bot] }), - noRemarks, - ); - assert.deepEqual(pushed.changes, [{ kind: "checks-passed", count: 1, required: false }]); - assert.deepEqual(pushed.next.awaitingApproval, []); - const gatedAgain = { ...approval, url: run(2) }; - const second = evaluatePullRequestWatch( - pushed.next, - detail({ headSha: "bbbbbbbbbb", checks: [bot, gatedAgain] }), - noRemarks, - ); - assert.deepEqual(second.changes, [{ kind: "checks-awaiting-approval", checks: [gatedAgain] }]); + const job = { ...check("lint", "success"), url: `${run(1)}/job/7` }; + const done = evaluatePullRequestWatch(missing.next, detail({ checks: [bot, job] }), noRemarks); + assert.deepEqual(done.changes, [{ kind: "checks-passed", count: 2, required: false }]); }); - it("keeps the last state when a host cannot tell whether workflows await approval", () => { + it("does not pass or fail while the host cannot tell whether workflows await approval", () => { const bot = check("bot", "success"); const unknown = { ...check("Workflow approval status", "action-required"), @@ -213,225 +121,20 @@ describe("evaluatePullRequestWatch", () => { assert.isFalse(unread.next.passed); const read = evaluatePullRequestWatch(unread.next, detail({ checks: [bot] }), noRemarks); assert.deepEqual(read.changes, [{ kind: "checks-passed", count: 1, required: false }]); - const again = evaluatePullRequestWatch( - read.next, - detail({ checks: [bot, unknown] }), - noRemarks, - ); - assert.deepEqual(again.changes, []); - assert.isTrue(again.next.passed); - assert.deepEqual( - evaluatePullRequestWatch(again.next, detail({ checks: [bot] }), noRemarks).changes, - [], - ); }); - it("ends a pass on a known failure, pending check, or approval beside an unread one", () => { - const unknown = { - ...check("Workflow approval status", "action-required"), - url: null, - placeholder: true, - }; - const required = (status: PullRequestCheck["status"]) => ({ - ...check("test", status), - required: true, - }); - const green = detail({ checks: [required("success"), check("e2e", "pending")] }); - const first = evaluatePullRequestWatch(watch(), green, noRemarks); - assert.deepEqual(first.next.owedChecks, ["e2e"]); - // Only good news beside the unread approvals keeps the state, owed checks included. - const blind = evaluatePullRequestWatch( - first.next, - detail({ checks: [...green.checks, unknown] }), - noRemarks, - ); - assert.deepEqual(blind.changes, []); - assert.deepEqual(blind.next.owedChecks, ["e2e"]); - - const broken = required("failure"); - const failed = evaluatePullRequestWatch( - blind.next, - detail({ checks: [broken, unknown] }), - noRemarks, - ); - assert.deepEqual(failed.changes, [{ kind: "checks-failed", failed: [broken] }]); - assert.isFalse(failed.next.passed); - const fixed = evaluatePullRequestWatch( - failed.next, - detail({ checks: [required("success")] }), - noRemarks, - ); - assert.deepEqual(fixed.changes, [{ kind: "checks-passed", count: 1, required: true }]); - - // Where nothing is required, a rerun pending beside the unread approvals ends the pass too. - const bot = check("bot", "success"); - const passed = evaluatePullRequestWatch(watch(), detail({ checks: [bot] }), noRemarks); - const rerun = evaluatePullRequestWatch( - passed.next, - detail({ checks: [check("bot", "pending"), unknown] }), - noRemarks, - ); - assert.deepEqual(rerun.changes, []); - assert.isFalse(rerun.next.passed); - const done = evaluatePullRequestWatch(rerun.next, detail({ checks: [bot] }), noRemarks); - assert.deepEqual(done.changes, [{ kind: "checks-passed", count: 1, required: false }]); - - // So does a run listed as awaiting approval while others could not be read. + it("still reports failures beside workflows awaiting approval", () => { const approval = { ...check("CI", "action-required"), url: run(1) }; - const gated = evaluatePullRequestWatch( - passed.next, - detail({ checks: [bot, approval, unknown] }), - noRemarks, - ); - assert.deepEqual(gated.changes, [{ kind: "checks-awaiting-approval", checks: [approval] }]); - assert.isFalse(gated.next.passed); - }); - - it("keeps failures and approvals through a read that lists only an unread approval", () => { - const unknown = { - ...check("Workflow approval status", "action-required"), - url: null, - placeholder: true, - }; - const broken = check("CI", "failure"); - const approval = { ...check("contributor tests", "action-required"), url: null }; - const seen = evaluatePullRequestWatch( + const broken = check("lint", "failure"); + const report = evaluatePullRequestWatch( watch(), - detail({ checks: [broken, approval] }), + detail({ checks: [approval, broken] }), noRemarks, ); - assert.deepEqual(seen.changes, [ + assert.deepEqual(report.changes, [ { kind: "checks-failed", failed: [broken] }, { kind: "checks-awaiting-approval", checks: [approval] }, ]); - const blind = evaluatePullRequestWatch(seen.next, detail({ checks: [unknown] }), noRemarks); - assert.deepEqual(blind.changes, []); - assert.deepEqual(blind.next, seen.next); - assert.deepEqual( - evaluatePullRequestWatch(blind.next, detail({ checks: [broken, approval] }), noRemarks) - .changes, - [], - ); - const pushed = evaluatePullRequestWatch( - blind.next, - detail({ headSha: "bbbbbbbbbb", checks: [unknown] }), - noRemarks, - ); - assert.deepEqual([pushed.next.failedChecks, pushed.next.awaitingApproval], [[], []]); - }); - - it("keeps failures and approvals an unread read misses while updating the checks it lists", () => { - const unknown = { - ...check("Workflow approval status", "action-required"), - url: null, - placeholder: true, - }; - const bot = check("bot", "success"); - const broken = check("CI", "failure"); - const approval = { ...check("contributor tests", "action-required"), url: null }; - const seen = evaluatePullRequestWatch( - watch(), - detail({ checks: [broken, approval] }), - noRemarks, - ); - const blind = evaluatePullRequestWatch( - seen.next, - detail({ checks: [bot, unknown] }), - noRemarks, - ); - assert.deepEqual(blind.changes, []); - assert.deepEqual( - [blind.next.failedChecks, blind.next.awaitingApproval], - [["CI"], ["contributor tests"]], - ); - assert.deepEqual( - evaluatePullRequestWatch(blind.next, detail({ checks: [broken, approval] }), noRemarks) - .changes, - [], - ); - - // What the read does list still counts: a new failure wakes, a settled check is let go. - const lint = check("lint", "failure"); - const listed = evaluatePullRequestWatch( - blind.next, - detail({ - checks: [check("CI", "pending"), check("contributor tests", "success"), lint, unknown], - }), - noRemarks, - ); - assert.deepEqual(listed.changes, [{ kind: "checks-failed", failed: [lint] }]); - assert.deepEqual([listed.next.failedChecks, listed.next.awaitingApproval], [["lint"], []]); - - // A read that could tell keeps nothing it does not list, and a push keeps nothing at all. - const whole = evaluatePullRequestWatch(blind.next, detail({ checks: [bot] }), noRemarks); - assert.deepEqual([whole.next.failedChecks, whole.next.awaitingApproval], [[], []]); - const pushed = evaluatePullRequestWatch( - blind.next, - detail({ headSha: "bbbbbbbbbb", checks: [bot, unknown] }), - noRemarks, - ); - assert.deepEqual([pushed.next.failedChecks, pushed.next.awaitingApproval], [[], []]); - }); - - it("waits for an approval with no URL while the host lists it", () => { - const bot = check("bot", "success"); - const green = evaluatePullRequestWatch(watch(), detail({ checks: [bot] }), noRemarks); - assert.isTrue(green.next.passed); - const approval = { ...check("contributor tests", "action-required"), url: null }; - const gated = evaluatePullRequestWatch( - green.next, - detail({ checks: [bot, approval] }), - noRemarks, - ); - assert.deepEqual(gated.changes, [{ kind: "checks-awaiting-approval", checks: [approval] }]); - assert.isFalse(gated.next.passed); - const still = evaluatePullRequestWatch( - gated.next, - detail({ checks: [bot, approval] }), - noRemarks, - ); - assert.deepEqual(still.changes, []); - // Nothing else names it, so it is held only while listed, unlike a workflow run. - const done = evaluatePullRequestWatch( - still.next, - detail({ checks: [bot, check("contributor tests", "success")] }), - noRemarks, - ); - assert.deepEqual(done.changes, [{ kind: "checks-passed", count: 2, required: false }]); - assert.deepEqual(done.next.awaitingApproval, []); - }); - - it("keeps the head and awaited approvals through a read that names no head", () => { - const bot = check("bot", "success"); - const approval = { ...check("CI", "action-required"), url: run(1) }; - const unknown = { - ...check("Workflow approval status", "action-required"), - url: null, - placeholder: true, - }; - const first = evaluatePullRequestWatch(watch(), detail({ checks: [bot, approval] }), noRemarks); - const { headSha: _headSha, ...headless } = detail({ checks: [bot, unknown] }); - const blind = evaluatePullRequestWatch(first.next, headless, noRemarks); - assert.deepEqual(blind.changes, []); - assert.equal(blind.next.headSha, "aaaaaaaaaa"); - assert.deepEqual(blind.next.awaitingApproval, [run(1)]); - const same = evaluatePullRequestWatch(blind.next, detail({ checks: [bot] }), noRemarks); - assert.deepEqual(same.changes, []); - assert.isFalse(same.next.passed); - const pushed = evaluatePullRequestWatch( - same.next, - detail({ headSha: "bbbbbbbbbb", checks: [bot] }), - noRemarks, - ); - assert.deepEqual(pushed.changes, [{ kind: "checks-passed", count: 1, required: false }]); - }); - - it("does not tell a watch saved before approvals were recorded about one it reported", () => { - const approval = { ...check("CI", "action-required"), url: run(1) }; - const saved = watch({ headSha: "aaaaaaaaaa", failedChecks: ["CI"] }); - const report = evaluatePullRequestWatch(saved, detail({ checks: [approval] }), noRemarks); - assert.deepEqual(report.changes, []); - assert.deepEqual(report.next.awaitingApproval, [run(1)]); }); it("reports each failure at once, even while another check never finishes", () => { @@ -752,15 +455,6 @@ describe("evaluatePullRequestWatch", () => { assert.deepEqual(pushed.flat(), ["no-checks"]); }); - it("waits for checks through reads that list only an unread approval", () => { - const unknown = { - ...check("Workflow approval status", "action-required"), - url: null, - placeholder: true, - }; - assert.deepEqual(anHourLater(watch(), detail({ checks: [unknown] })).flat(), ["no-checks"]); - }); - it("never reports missing checks on a host where an empty list says nothing", () => { const silent = detail({ capabilities: capabilities(false), checks: [] }); assert.isTrue(anHourLater(watch(), silent).every((kinds) => kinds.length === 0)); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 85a370e6d7194..31e8c04f30f5a 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -19,7 +19,6 @@ import { type ModelSelection, type OrchestrationV2Run, ProjectId, - type PullRequestCheck, type PullRequestDetail, type PullRequestComment, PullRequestOperationError, @@ -2892,125 +2891,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - // regression: #24575 fork workflows awaiting approval flapped between failed and passed - it.effect("keeps awaited workflow approvals across empty reads, a restart, and a re-watch", () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const threadId = ThreadId.make("runtime-pull-request-watch-approval"); - const projectId = ProjectId.make("pr-watch-approval-project"); - yield* seedProject({ - projectId, - title: "Watch approval", - workspaceRoot: "/workspace/watch-approval", - defaultModelSelection: null, - createdAt: "2026-10-01T00:00:00.000Z", - }); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("pr-watch-approval-create"), - threadId, - projectId, - title: "Watch approval", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - const watch = (on: boolean, id: string) => - orchestrator.dispatch({ - type: "thread.pull-request.watch", - commandId: CommandId.make(`pr-watch-approval-${id}`), - threadId, - host: "github.com", - repository: "pingdotgg/t3code", - number: 12, - watching: on, - link: { url: "https://github.com/pingdotgg/t3code/pull/12", source: "agent" }, - }); - yield* watch(true, "start"); - - const run = (id: number) => `https://github.com/pingdotgg/t3code/actions/runs/${id}`; - const bot = { name: "bot", status: "success" as const, description: null, url: null }; - const approval = (id: number) => ({ - name: `Workflow ${id}`, - status: "action-required" as const, - description: null, - url: run(id), - }); - const job = (id: number) => ({ - name: `job ${id}`, - status: "success" as const, - description: null, - url: `${run(id)}/job/${id}0`, - }); - let checks: ReadonlyArray = [bot, approval(1), approval(2)]; - const host = Layer.mergeAll( - NodeServices.layer, - Layer.mock(PullRequestService.PullRequestService)({ - detail: () => - Effect.sync(() => ({ - ...watchedPullRequestDetail({ - projectId, - number: 12, - at: "2026-10-02T12:00:00.000Z", - }), - headSha: "ad70160aaa", - checks, - })), - activity: () => - Effect.succeed({ - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }), - }), - ); - const summaries = Effect.map( - orchestrator.getThreadRecords(threadId, ["messages"]), - ({ messages }) => messages.flatMap((message) => message.notification?.summary ?? []), - ); - const awaited = Effect.map( - orchestrator.getThreadShell(threadId), - (thread) => thread?.pullRequests?.[0]?.watch?.awaitingApproval, - ); - const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); - const pass = (target: typeof reactor) => - Effect.gen(function* () { - yield* TestClock.adjust("10 minutes"); - yield* target.sweep; - yield* worker.drain(); - }); - yield* pass(reactor); - assert.deepEqual(yield* summaries, ["#12: checks await approval"]); - - // One run finishing changes only what is awaited, which is still saved without a wake. - checks = [bot, approval(1), job(2)]; - yield* pass(reactor); - assert.deepEqual(yield* awaited, [run(1)]); - - // The approval read answering empty, after a restart and a re-watch, is still not a pass. - checks = [bot, job(2)]; - const restarted = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); - yield* pass(restarted); - yield* watch(false, "stop"); - yield* watch(true, "again"); - yield* pass(restarted); - assert.deepEqual(yield* summaries, ["#12: checks await approval"]); - assert.deepEqual(yield* awaited, [run(1)]); - - checks = [bot, job(1), job(2)]; - yield* pass(restarted); - assert.deepEqual(yield* summaries, ["#12: checks await approval", "#12: checks passed"]); - yield* watch(false, "end"); - }), - ); - // regression: #21155 watching a pull request again repeated news the thread already got it.effect("a watch that ends on the comment limit keeps what its last pass saw", () => Effect.gen(function* () { From d07d7bf987b103536e24d8778a04a8972b5f1596 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 20:44:14 +0000 Subject: [PATCH 07/11] test(server): check a push clears awaited workflow approvals Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/pullRequestWatch.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 22840e8ff90f7..1b39f0482c7cf 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -102,6 +102,10 @@ describe("evaluatePullRequestWatch", () => { assert.deepEqual(missing.changes, []); assert.isFalse(missing.next.passed); assert.deepEqual(evaluatePullRequestWatch(missing.next, gated, noRemarks).changes, []); + const pushed = detail({ headSha: "bbbbbbbbbb", checks: [bot] }); + assert.deepEqual(evaluatePullRequestWatch(missing.next, pushed, noRemarks).changes, [ + { kind: "checks-passed", count: 1, required: false }, + ]); // Approved, the run's jobs carry their own names under the run's URL. const job = { ...check("lint", "success"), url: `${run(1)}/job/7` }; From 60b59d67e71e822a4dc863d13265670e37048494 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 02:34:29 +0000 Subject: [PATCH 08/11] test(server): verify approval watch recovery and persistence --- .../orchestration-v2/pullRequestWatch.test.ts | 12 + .../src/orchestration-v2/runtimeLayer.test.ts | 233 +++++++++--------- 2 files changed, 133 insertions(+), 112 deletions(-) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 1b39f0482c7cf..e1f52d4584572 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -125,6 +125,18 @@ describe("evaluatePullRequestWatch", () => { assert.isFalse(unread.next.passed); const read = evaluatePullRequestWatch(unread.next, detail({ checks: [bot] }), noRemarks); assert.deepEqual(read.changes, [{ kind: "checks-passed", count: 1, required: false }]); + for (const status of ["failure", "pending"] as const) { + const blocked = evaluatePullRequestWatch( + read.next, + detail({ checks: [{ ...bot, status }, unknown] }), + noRemarks, + ); + assert.isFalse(blocked.next.passed); + assert.deepEqual( + evaluatePullRequestWatch(blocked.next, detail({ checks: [bot] }), noRemarks).changes, + [{ kind: "checks-passed", count: 1, required: false }], + ); + } }); it("still reports failures beside workflows awaiting approval", () => { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 025191f098823..9a693896390bd 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2767,125 +2767,134 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - it.effect("finishes owed checks once after pending sweeps, a restart, and a re-watch", () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const threadId = ThreadId.make("runtime-pull-request-watch-owed"); - const projectId = ProjectId.make("pr-watch-owed-project"); - yield* seedProject({ - projectId, - title: "Watch owed", - workspaceRoot: "/workspace/watch-owed", - defaultModelSelection: null, - createdAt: "2026-10-01T00:00:00.000Z", - }); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("pr-watch-owed-create"), - threadId, - projectId, - title: "Watch owed", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - const watch = (on: boolean, id: string) => - orchestrator.dispatch({ - type: "thread.pull-request.watch", - commandId: CommandId.make(`pr-watch-owed-${id}`), + it.effect.each(["pending", "action-required"] as const)( + "finishes %s checks once after sweeps, a restart, and a re-watch", + (initialStatus) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make(`runtime-pull-request-watch-owed-${initialStatus}`); + const projectId = ProjectId.make(`pr-watch-owed-project-${initialStatus}`); + yield* seedProject({ + projectId, + title: "Watch owed", + workspaceRoot: "/workspace/watch-owed", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`pr-watch-owed-create-${initialStatus}`), threadId, - host: "github.com", - repository: "pingdotgg/t3code", - number: 12, - watching: on, - link: { url: "https://github.com/pingdotgg/t3code/pull/12", source: "agent" }, + projectId, + title: "Watch owed", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, }); - yield* watch(true, "start"); - - let remainingStatus: "pending" | "success" = "pending"; - const host = Layer.mergeAll( - NodeServices.layer, - Layer.mock(PullRequestService.PullRequestService)({ - detail: () => - Effect.sync(() => ({ - ...watchedPullRequestDetail({ - projectId, - number: 12, - at: "2026-10-02T12:00:00.000Z", - }), - headSha: "ad70160aaa", - checks: ["lint", "test"].map((name) => ({ - name, - status: name === "test" ? remainingStatus : ("success" as const), - description: null, - url: null, - required: name === "lint", + const watch = (on: boolean, id: string) => + orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-owed-${initialStatus}-${id}`), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 12, + watching: on, + link: { url: "https://github.com/pingdotgg/t3code/pull/12", source: "agent" }, + }); + yield* watch(true, "start"); + + let remainingStatus: "pending" | "action-required" | "success" = initialStatus; + const approval = initialStatus === "action-required"; + const firstSummary = approval ? "#12: checks await approval" : "#12: checks passed"; + const runUrl = "https://github.com/pingdotgg/t3code/actions/runs/123"; + const host = Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => + Effect.sync(() => ({ + ...watchedPullRequestDetail({ + projectId, + number: 12, + at: "2026-10-02T12:00:00.000Z", + }), + headSha: "ad70160aaa", + checks: ["lint", "test"].map((name) => ({ + name, + status: name === "test" ? remainingStatus : ("success" as const), + description: null, + url: name === "test" ? `${runUrl}/job/7` : null, + required: name === "lint", + })), })), - })), - activity: () => - Effect.succeed({ - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }), - }), - ); - const summaries = Effect.map( - orchestrator.getThreadRecords(threadId, ["messages"]), - ({ messages }) => messages.flatMap((message) => message.notification?.summary ?? []), - ); + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ); + const summaries = Effect.map( + orchestrator.getThreadRecords(threadId, ["messages"]), + ({ messages }) => messages.flatMap((message) => message.notification?.summary ?? []), + ); - const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); - yield* reactor.sweep; - yield* worker.drain(); - yield* TestClock.adjust("10 minutes"); - yield* reactor.sweep; - yield* worker.drain(); - assert.deepEqual(yield* summaries, ["#12: checks passed"]); + const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + yield* reactor.sweep; + yield* worker.drain(); + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + yield* worker.drain(); + assert.deepEqual(yield* summaries, [firstSummary]); - assert.deepEqual( - (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.owedChecks, - ["test"], - ); - // The fresh reactor reloads the nonempty obligation from the persisted watch. - const restarted = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); - yield* restarted.sweep; - yield* worker.drain(); - assert.deepEqual(yield* summaries, ["#12: checks passed"]); + assert.deepEqual( + (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.[ + approval ? "awaitingApproval" : "owedChecks" + ], + approval ? [runUrl] : ["test"], + ); + // The fresh reactor reloads the nonempty obligation from the persisted watch. + const restarted = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + yield* restarted.sweep; + yield* worker.drain(); + assert.deepEqual(yield* summaries, [firstSummary]); - // A new watch on the same pull request knows what the last one told. - yield* watch(false, "stop"); - yield* watch(true, "again"); - yield* restarted.sweep; - yield* worker.drain(); - assert.deepEqual(yield* summaries, ["#12: checks passed"]); + // A new watch on the same pull request knows what the last one told. + yield* watch(false, "stop"); + yield* watch(true, "again"); + yield* restarted.sweep; + yield* worker.drain(); + assert.deepEqual(yield* summaries, [firstSummary]); - assert.deepEqual( - (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.owedChecks, - ["test"], - ); - remainingStatus = "success"; - yield* TestClock.adjust("10 minutes"); - yield* restarted.sweep; - yield* worker.drain(); - assert.deepEqual(yield* summaries, ["#12: checks passed", "#12: checks passed"]); - yield* TestClock.adjust("10 minutes"); - yield* restarted.sweep; - yield* worker.drain(); - assert.deepEqual(yield* summaries, ["#12: checks passed", "#12: checks passed"]); - assert.deepEqual( - (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.owedChecks, - [], - ); - yield* watch(false, "end"); - }), + assert.deepEqual( + (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.[ + approval ? "awaitingApproval" : "owedChecks" + ], + approval ? [runUrl] : ["test"], + ); + remainingStatus = "success"; + yield* TestClock.adjust("10 minutes"); + yield* restarted.sweep; + yield* worker.drain(); + assert.deepEqual(yield* summaries, [firstSummary, "#12: checks passed"]); + yield* TestClock.adjust("10 minutes"); + yield* restarted.sweep; + yield* worker.drain(); + assert.deepEqual(yield* summaries, [firstSummary, "#12: checks passed"]); + assert.deepEqual( + (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.owedChecks, + [], + ); + yield* watch(false, "end"); + }), ); // regression: #21155 watching a pull request again repeated news the thread already got From f452f47dcf5e830fcb1c09f42b94a5006efdd235 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 02:38:40 +0000 Subject: [PATCH 09/11] test(server): keep watch parameterization diff focused --- .../src/orchestration-v2/runtimeLayer.test.ts | 240 +++++++++--------- 1 file changed, 119 insertions(+), 121 deletions(-) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 9a693896390bd..14ac1ae86dcc3 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2767,134 +2767,132 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - it.effect.each(["pending", "action-required"] as const)( - "finishes %s checks once after sweeps, a restart, and a re-watch", - (initialStatus) => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const threadId = ThreadId.make(`runtime-pull-request-watch-owed-${initialStatus}`); - const projectId = ProjectId.make(`pr-watch-owed-project-${initialStatus}`); - yield* seedProject({ - projectId, - title: "Watch owed", - workspaceRoot: "/workspace/watch-owed", - defaultModelSelection: null, - createdAt: "2026-10-01T00:00:00.000Z", - }); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`pr-watch-owed-create-${initialStatus}`), + it.effect.each(["pending", "action-required"] as const)("recovers %s watches", (initialStatus) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make(`runtime-pull-request-watch-owed-${initialStatus}`); + const projectId = ProjectId.make(`pr-watch-owed-project-${initialStatus}`); + yield* seedProject({ + projectId, + title: "Watch owed", + workspaceRoot: "/workspace/watch-owed", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`pr-watch-owed-create-${initialStatus}`), + threadId, + projectId, + title: "Watch owed", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const watch = (on: boolean, id: string) => + orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-owed-${initialStatus}-${id}`), threadId, - projectId, - title: "Watch owed", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, + host: "github.com", + repository: "pingdotgg/t3code", + number: 12, + watching: on, + link: { url: "https://github.com/pingdotgg/t3code/pull/12", source: "agent" }, }); - const watch = (on: boolean, id: string) => - orchestrator.dispatch({ - type: "thread.pull-request.watch", - commandId: CommandId.make(`pr-watch-owed-${initialStatus}-${id}`), - threadId, - host: "github.com", - repository: "pingdotgg/t3code", - number: 12, - watching: on, - link: { url: "https://github.com/pingdotgg/t3code/pull/12", source: "agent" }, - }); - yield* watch(true, "start"); - - let remainingStatus: "pending" | "action-required" | "success" = initialStatus; - const approval = initialStatus === "action-required"; - const firstSummary = approval ? "#12: checks await approval" : "#12: checks passed"; - const runUrl = "https://github.com/pingdotgg/t3code/actions/runs/123"; - const host = Layer.mergeAll( - NodeServices.layer, - Layer.mock(PullRequestService.PullRequestService)({ - detail: () => - Effect.sync(() => ({ - ...watchedPullRequestDetail({ - projectId, - number: 12, - at: "2026-10-02T12:00:00.000Z", - }), - headSha: "ad70160aaa", - checks: ["lint", "test"].map((name) => ({ - name, - status: name === "test" ? remainingStatus : ("success" as const), - description: null, - url: name === "test" ? `${runUrl}/job/7` : null, - required: name === "lint", - })), - })), - activity: () => - Effect.succeed({ - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads: [], - commits: [], + yield* watch(true, "start"); + + let remainingStatus: "pending" | "action-required" | "success" = initialStatus; + const approval = initialStatus === "action-required"; + const firstSummary = approval ? "#12: checks await approval" : "#12: checks passed"; + const runUrl = "https://github.com/pingdotgg/t3code/actions/runs/123"; + const host = Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => + Effect.sync(() => ({ + ...watchedPullRequestDetail({ + projectId, + number: 12, + at: "2026-10-02T12:00:00.000Z", }), - }), - ); - const summaries = Effect.map( - orchestrator.getThreadRecords(threadId, ["messages"]), - ({ messages }) => messages.flatMap((message) => message.notification?.summary ?? []), - ); + headSha: "ad70160aaa", + checks: ["lint", "test"].map((name) => ({ + name, + status: name === "test" ? remainingStatus : ("success" as const), + description: null, + url: name === "test" ? `${runUrl}/job/7` : null, + required: name === "lint", + })), + })), + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ); + const summaries = Effect.map( + orchestrator.getThreadRecords(threadId, ["messages"]), + ({ messages }) => messages.flatMap((message) => message.notification?.summary ?? []), + ); - const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); - yield* reactor.sweep; - yield* worker.drain(); - yield* TestClock.adjust("10 minutes"); - yield* reactor.sweep; - yield* worker.drain(); - assert.deepEqual(yield* summaries, [firstSummary]); + const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + yield* reactor.sweep; + yield* worker.drain(); + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + yield* worker.drain(); + assert.deepEqual(yield* summaries, [firstSummary]); - assert.deepEqual( - (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.[ - approval ? "awaitingApproval" : "owedChecks" - ], - approval ? [runUrl] : ["test"], - ); - // The fresh reactor reloads the nonempty obligation from the persisted watch. - const restarted = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); - yield* restarted.sweep; - yield* worker.drain(); - assert.deepEqual(yield* summaries, [firstSummary]); + assert.deepEqual( + (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.[ + approval ? "awaitingApproval" : "owedChecks" + ], + approval ? [runUrl] : ["test"], + ); + // The fresh reactor reloads the nonempty obligation from the persisted watch. + const restarted = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + yield* restarted.sweep; + yield* worker.drain(); + assert.deepEqual(yield* summaries, [firstSummary]); - // A new watch on the same pull request knows what the last one told. - yield* watch(false, "stop"); - yield* watch(true, "again"); - yield* restarted.sweep; - yield* worker.drain(); - assert.deepEqual(yield* summaries, [firstSummary]); + // A new watch on the same pull request knows what the last one told. + yield* watch(false, "stop"); + yield* watch(true, "again"); + yield* restarted.sweep; + yield* worker.drain(); + assert.deepEqual(yield* summaries, [firstSummary]); - assert.deepEqual( - (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.[ - approval ? "awaitingApproval" : "owedChecks" - ], - approval ? [runUrl] : ["test"], - ); - remainingStatus = "success"; - yield* TestClock.adjust("10 minutes"); - yield* restarted.sweep; - yield* worker.drain(); - assert.deepEqual(yield* summaries, [firstSummary, "#12: checks passed"]); - yield* TestClock.adjust("10 minutes"); - yield* restarted.sweep; - yield* worker.drain(); - assert.deepEqual(yield* summaries, [firstSummary, "#12: checks passed"]); - assert.deepEqual( - (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.owedChecks, - [], - ); - yield* watch(false, "end"); - }), + assert.deepEqual( + (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.[ + approval ? "awaitingApproval" : "owedChecks" + ], + approval ? [runUrl] : ["test"], + ); + remainingStatus = "success"; + yield* TestClock.adjust("10 minutes"); + yield* restarted.sweep; + yield* worker.drain(); + assert.deepEqual(yield* summaries, [firstSummary, "#12: checks passed"]); + yield* TestClock.adjust("10 minutes"); + yield* restarted.sweep; + yield* worker.drain(); + assert.deepEqual(yield* summaries, [firstSummary, "#12: checks passed"]); + assert.deepEqual( + (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.owedChecks, + [], + ); + yield* watch(false, "end"); + }), ); // regression: #21155 watching a pull request again repeated news the thread already got From 0fb44ea93c286cff819ec5c2de9781b8b5bbce3e Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 29 Oct 2026 14:44:07 +0000 Subject: [PATCH 10/11] test(server): account for the approval-watch tool description --- apps/server/src/provider/agentContextBudget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index d5496ea8cfc57..9de77a66b6d74 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5135, "codexAdditionalContextBytes": 6865, - "mcpToolListJsonBytes": 45907 + "mcpToolListJsonBytes": 45944 } From 9a08bbfb2e7a1252e20db131a0ecf9e9d0e2eb10 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 29 Oct 2026 23:57:34 +0000 Subject: [PATCH 11/11] fix(server): keep the PR watch within the agent context budget --- apps/server/src/mcp/toolkits/pullRequests/tools.ts | 2 +- apps/server/src/provider/agentContextBudget.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index acf5247a1eb21..866580fc5e029 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -316,7 +316,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { const WatchPullRequestTool = Tool.make("watch_pull_request", { description: - "Watch an open PR for this thread, linking it first if needed. T3 wakes you on failed checks, checks awaiting maintainer approval, passed required checks, new comments or reviews, or conflicts. Handle old comments first. Only later ones wake you. After this call, end your turn. The watch stays on after each wake, so do not call this again on a wake. A wake is not a merge decision. Check readiness yourself.", + "Watch an open PR for this thread, linking it first if needed. T3 wakes you on failed checks, passed required checks, new comments or reviews, or conflicts. Handle old comments first. Only later ones wake you. After this call, end your turn. The watch stays on after each wake, so do not call this again on a wake. A wake is not a merge decision. Check readiness yourself.", parameters: PullRequestTargetInput, success: WatchPullRequestResult, failure: PullRequestToolError, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index c8cb075c36e58..1711e148d4ca4 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5135, "codexAdditionalContextBytes": 6865, - "mcpToolListJsonBytes": 46044 + "mcpToolListJsonBytes": 46007 }