From a9242513077047f3849b551c4b3acd6589faf874 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:23:24 -0800 Subject: [PATCH 01/11] fix(server): a watched PR stops holding its thread once its checks finish A watch kept its thread in Working until the agent unwatched or the PR merged, so a PR left green for human review stranded the thread for hours. The watch now records whether any check on the head is still running, and holds the thread only then. Review bots that report as checks (CodeRabbit, Macroscope) keep it waiting; once everything finished, the thread returns to the inbox while the watch stays on for comments, failures, conflicts or the merge. Adds four mcp-tool-use guards for unwatching on green wakes. Co-Authored-By: Claude Opus 5.5 --- .../PullRequestWatchReactor.ts | 3 +- .../orchestration-v2/pullRequestWatch.test.ts | 39 +++++++++ .../src/orchestration-v2/pullRequestWatch.ts | 8 ++ .../src/orchestration-v2/runtimeLayer.test.ts | 87 +++++++++++++++++++ docs/user/source-control.md | 8 +- docs/user/thread-sidebar.md | 2 +- evals/mcp-tool-use/cases/pull-requests.ndjson | 4 + packages/contracts/src/threadPullRequest.ts | 5 ++ ...chestrationV2PendingBackgroundWork.test.ts | 25 ++++++ .../orchestrationV2PendingBackgroundWork.ts | 3 +- 10 files changed, 178 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 6eca0cb39aa2..de30731739c9 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -186,7 +186,8 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc left.remarkIds.join("\n") === right.remarkIds.join("\n") && left.conflicting === right.conflicting && left.wakes === right.wakes && - left.checksAwaitedSince === right.checksAwaitedSince + left.checksAwaitedSince === right.checksAwaitedSince && + left.checksPending === right.checksPending ); } diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index dece3e344983..1973fb6aad46 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -583,4 +583,43 @@ describe("evaluatePullRequestWatch", () => { const silent = detail({ capabilities: capabilities(false), checks: [] }); assert.isTrue(anHourLater(watch(), silent).every((kinds) => kinds.length === 0)); }); + + it("counts checks as pending until every one, a review bot's included, has finished", () => { + const pending = (checks: ReadonlyArray, from = watch()) => + evaluatePullRequestWatch(from, detail({ checks }), noRemarks).next.checksPending; + const ci = { ...check("test", "success"), required: true }; + assert.isTrue(pending([ci, check("CodeRabbit", "pending")])); + assert.isFalse(pending([ci, check("CodeRabbit", "success")])); + assert.isFalse(pending([ci, check("CodeRabbit", "failure")])); + assert.isFalse(pending([ci, check("deploy", "action-required")])); + // A failed read keeps the last answer. + assert.isFalse(pending([], watch({ headSha: "aaaaaaaaaa", checksPending: false }))); + }); + + it("waits for a new head's checks only where the host always reports some", () => { + const pushed = (checksAlwaysReport: boolean) => + evaluatePullRequestWatch( + watch({ headSha: "aaaaaaaaaa", checksPending: false }), + detail({ + headSha: "bbbbbbbbbb", + capabilities: capabilities(checksAlwaysReport), + checks: [], + }), + noRemarks, + ).next.checksPending; + assert.isTrue(pushed(true)); + assert.isFalse(pushed(false)); + + // Once told none reported, the watch stops waiting for them. + let current = watch({ headSha: "aaaaaaaaaa" }); + for (const minute of [0, 20]) { + current = evaluate( + current, + detail({ headSha: "bbbbbbbbbb", checks: [] }), + noRemarks, + minute * 60_000, + ).next; + } + assert.isFalse(current.checksPending); + }); }); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 046c48e1858b..216fe9fd84ce 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -56,12 +56,18 @@ export function evaluatePullRequestWatch( let owedChecks = headMoved ? [] : (watch.owedChecks ?? []); let awaitingApproval = headMoved ? [] : (watch.awaitingApproval ?? []); let approvalAnnounced = headMoved ? false : (watch.approvalAnnounced ?? false); + // A new head without checks waits for them only where the host always reports some. + let checksPending = headMoved + ? detail.capabilities.checksAlwaysReport === true + : (watch.checksPending ?? true); // An unavailable approval read cannot establish success or that no checks exist. const unread = detail.checks.some((check) => check.placeholder === true); 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) { + // A run awaiting approval waits on a person, like a review, so only running checks count. + checksPending = checks.some((check) => check.status === "pending"); const newlyFailed = failed.filter((check) => !failedChecks.includes(check.name)); if (newlyFailed.length > 0) changes.push({ kind: "checks-failed", failed: newlyFailed }); if (!approvalAnnounced && awaiting.length > 0) { @@ -154,6 +160,7 @@ export function evaluatePullRequestWatch( if (!unread && awaitedMs >= PULL_REQUEST_WATCH_NO_CHECKS_MS) { changes.push({ kind: "no-checks", minutes: Math.round(awaitedMs / 60_000) }); checksAwaitedSince = null; + checksPending = false; } } @@ -205,6 +212,7 @@ export function evaluatePullRequestWatch( conflicting, wakes, checksAwaitedSince, + checksPending, }, exhausted: commentsOnly && wakes >= PULL_REQUEST_WATCH_WAKE_LIMIT, }; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 25ccef8c1108..f28a741e41f0 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -24,6 +24,7 @@ import { type OrchestrationV2ShellSnapshot, type OrchestrationV2RuntimeRequest, ProjectId, + type PullRequestCheck, type PullRequestDetail, type PullRequestComment, PullRequestOperationError, @@ -3532,6 +3533,92 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("records when a watched PR's last checks finish, even with no news to wake for", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-quiet"); + const projectId = ProjectId.make("pr-watch-quiet-project"); + yield* seedProject({ + projectId, + title: "Watch quiet", + workspaceRoot: "/workspace/watch-quiet", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-quiet-create"), + threadId, + projectId, + title: "Watch quiet", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-quiet-start"), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 17, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/17", source: "agent" }, + }); + + let e2e: PullRequestCheck["status"] = "pending"; + const host = Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => + Effect.sync(() => ({ + ...watchedPullRequestDetail({ + projectId, + number: 17, + at: "2026-10-02T12:00:00.000Z", + }), + checks: [ + { name: "test", status: "failure" as const, description: null, url: null }, + { name: "e2e", status: e2e, description: null, url: null }, + ], + })), + 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 checksPending = Effect.map( + orchestrator.getThreadShell(threadId), + (thread) => thread?.pullRequests?.[0]?.watch?.checksPending, + ); + + const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + yield* reactor.sweep; + assert.deepEqual(yield* summaries, ["#17: checks failed"]); + assert.isTrue(yield* checksPending); + + e2e = "success"; + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + assert.deepEqual(yield* summaries, ["#17: checks failed"]); + assert.isFalse(yield* checksPending); + }), + ); + it.effect.each(["pending", "action-required"] as const)("recovers %s watches", (initialStatus) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 8bbeb7e988cb..a0ad2a973699 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -337,9 +337,11 @@ watch already reported; unlinking the pull request clears that. Subagents cannot it yourself, use the row menu in the **Linked pull requests** panel. In the thread details card, a watched pull request shows an eye; click it to stop watching. -A watched thread counts as working between wakes, so it stays in the **Working** section and does -not auto-settle. Agents stop watching when they hand the work back to you, and the thread then -returns to your inbox. +A watched thread counts as working while the pull request still has checks running, so it stays in +the **Working** section and does not auto-settle. Once every check has finished, including review +bots that report as checks, the thread returns to your inbox: what happens next is up to a person. +The watch stays on and still wakes the agent on new comments, failed checks, conflicts or a merge. +Agents stop watching when they hand the work back to you. Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked out from the matching organization and repository. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 09ce63e2a3e6..c9d98bca8ccb 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -134,7 +134,7 @@ open. Threads that are working or waiting on work they will resume from move into a collapsed **Working** section below the active list. -That includes subagents, watched pull requests, and background commands or monitors the agent +That includes subagents, watched pull requests with checks still running, and background commands or monitors the agent says it is waiting on. A dev server or other process an agent leaves running does not keep a thread there. A thread returns to the top of the active list when it finishes, fails, or needs an approval or answer. The Working section lists the thread you last sent work to first. Pinned threads stay in diff --git a/evals/mcp-tool-use/cases/pull-requests.ndjson b/evals/mcp-tool-use/cases/pull-requests.ndjson index 2e14e45f7f25..85f89d2d7100 100644 --- a/evals/mcp-tool-use/cases/pull-requests.ndjson +++ b/evals/mcp-tool-use/cases/pull-requests.ndjson @@ -37,3 +37,7 @@ {"id": "link-one-pr", "area": "pull-requests", "privacy": "commit-safe", "kind": "control", "pattern": "single link stays single", "experience": "Linking one named PR is ordinary work.", "setup": {"role": "top", "context": "No pull requests are linked to this thread.", "world": {"repo": "acme/app", "branch": "main"}}, "prompt": "link https://github.com/acme/app/pull/130 here", "expect": {"calls": [{"tool": "link_pull_request", "args": {"url": "~/pull/130"}}], "notCalls": ["link_pull_requests"]}, "maxSteps": 6, "source": {"thread": "Synthetic control for batch link"}} {"id": "schedule-one-time-date", "area": "pull-requests", "kind": "bad", "pattern": "one-off date scheduled as a daily task", "experience": "Theo asked whether a scheduled task can run at a specific date and time. schedule_task only had daily times, intervals, and webhooks, so a one-off request became a daily task.", "source": {"report": "maintainer request, 2026-10-31: scheduled tasks could not run at one date and time"}, "rule": "schedule: {type:'once',runAt} for one date and time", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-4", "title": "Cut 2.0 release branch", "schedule": {"type": "once", "runAt": "2026-11-03T09:00:00.000Z"}, "enabled": true, "nextRunAt": "2026-11-03T09:00:00.000Z"}}}, "prompt": "On November 3rd at 9am UTC, start a new thread that cuts the 2.0 release branch from main in acme/app. Only that one time.", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "once", "schedule.runAt": "~11-03T09:00"}}], "notCalls": ["delegate_task", "run_scheduled_task_now"]}, "notes": "Mock result shape is an approximation."} {"id": "schedule-fresh-session-per-run", "area": "pull-requests", "kind": "guard", "pattern": "recurring task that should not accumulate context", "experience": "A recurring triage task kept one thread so the user could read its history, but each run carried every earlier run's context into the next.", "rule": "schedule_task `freshSession: true` keeps runs in one thread while each run starts a fresh session.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Today is Monday 2026-11-02, 10:00 local time.", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-7", "title": "Triage new issues", "enabled": true, "projectId": "project-1", "boundThreadId": null, "freshSession": true, "schedule": {"type": "fixed_time", "timeOfDay": "09:00"}, "nextRunAt": "2026-11-03T09:00:00-08:00", "lastRunStatus": "never"}}}, "prompt": "Every morning at 9, triage new issues in acme/app. Keep all the runs in one thread so I can scroll back, but each run should start clean without remembering the previous days.", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "fixed_time", "freshSession": true}}], "notCalls": ["delegate_task", "run_scheduled_task_now"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-11-03: automations in one thread with separate provider sessions"}, "notes": "schedule-one-off-tomorrow is the control: an ordinary schedule must not set freshSession."} +{"id":"green-wake-handback-unwatches","area":"pull-requests","kind":"guard","pattern":"green wake with only a person's review left: unwatches before saying it needs their approval","experience":"Asked to fix a bug and open a PR, the agent watched it. On the wake where every check and review bot had finished, it replied that the PR still needed the user's approval and ended the turn without unwatch_pull_request, so the thread sat in Working for hours.","source":{"thread":"Maintainer threads, 2026-10-09 to 2026-10-16: on green wakes Claude handed back with \"still needs your approval\" and kept the watch","report":"Discord, 2026-11-06: threads stuck waiting for PRs that went green hours ago (Claude Code)"},"rule":"When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.","privacy":"commit-safe","setup":{"role":"top","priorTurns":[{"role":"user","text":"The CLI hangs on Windows when stdin is a pipe (`echo hi | acme run`). Fix it and open a PR."},{"role":"assistant","text":"Fixed: on Windows a piped stdin never emitted `end`, so `readAll` waited forever. Opened [#124](https://github.com/acme/app/pull/124); T3 Code is watching it.","toolCalls":[{"tool":"Bash","input":{"command":"git push -u origin fix/windows-piped-stdin"},"result":"To github.com:acme/app.git\n * [new branch] fix/windows-piped-stdin -> fix/windows-piped-stdin\nbranch 'fix/windows-piped-stdin' set up to track 'origin/fix/windows-piped-stdin'."},{"tool":"Bash","input":{"command":"gh pr create --base main --title \"fix(cli): piped stdin no longer hangs on Windows\" --fill"},"result":"https://github.com/acme/app/pull/124"},{"tool":"link_pull_request","input":{"url":"https://github.com/acme/app/pull/124"},"result":{"host":"github.com","repository":"acme/app","number":124,"url":"https://github.com/acme/app/pull/124","alreadyLinked":false}},{"tool":"watch_pull_request","input":{"url":"https://github.com/acme/app/pull/124"},"result":{"host":"github.com","repository":"acme/app","number":124,"url":"https://github.com/acme/app/pull/124","watching":true,"wasWatching":false}}]}],"mocks":{"unwatch_pull_request":"default","watch_pull_request":"default","list_thread_pull_requests":"default","link_pull_request":"default"},"world":{"branch":"fix/windows-piped-stdin","commits":[{"sha":"abc1234","subject":"fix(cli): piped stdin no longer hangs on Windows"}],"diff":"diff --git a/packages/cli/src/stdin.ts b/packages/cli/src/stdin.ts\n--- a/packages/cli/src/stdin.ts\n+++ b/packages/cli/src/stdin.ts\n@@ -4,1 +4,2 @@\n- return process.stdin.isTTY ? readTty() : readAll(process.stdin);\n+ if (process.platform === \"win32\" && !process.stdin.isTTY) return readAll(process.stdin, { closeOnEnd: true });\n+ return process.stdin.isTTY ? readTty() : readAll(process.stdin);\n","prs":[{"number":124,"title":"fix(cli): piped stdin no longer hangs on Windows","headRefName":"fix/windows-piped-stdin","checks":[{"name":"Check","state":"pass"},{"name":"Lint","state":"pass"},{"name":"Typecheck","state":"pass"},{"name":"Test CLI","state":"pass"},{"name":"Test Web","state":"pass"},{"name":"Test Server","state":"pass"},{"name":"Build","state":"pass"},{"name":"CodeRabbit","state":"pass"},{"name":"Macroscope - Correctness Check","state":"pass"}],"mergeStateStatus":"BLOCKED","reviewDecision":"REVIEW_REQUIRED","comments":[{"author":"coderabbitai[bot]","body":"No actionable comments were generated in the recent review. 🎉"},{"author":"macroscope-app[bot]","body":"No bugs found. Approvability: needs human review, because this changes how the CLI reads stdin."}]}]}},"prompt":"Update on pull request #124 (https://github.com/acme/app/pull/124), which T3 Code is watching for you:\n- All 9 checks passed on abc1234.\n\nLook into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.","expect":{"calls":[{"tool":"unwatch_pull_request","args":{"url":"~/pull/124"}}],"notCalls":["watch_pull_request","link_pull_request","list_thread_pull_requests","Monitor","ScheduleWakeup"],"shellMustNot":["gh pr merge","\\bsleep \\d","gh pr checks[^\\n]*--watch","gh run watch","\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b"]},"notes":"Wake text is copied from pullRequestWatch.ts. Every check, CodeRabbit and Macroscope included, has finished; only a human approval remains, which the agent was not asked to wait for. No replay reproduced the real misses, which came from long threads with several PRs, so this guards the handback rather than reproducing it. On main's wake text (2026-11-08), Sonnet 5.5 and Opus 5.5 passed 4 of 4 each.","maxSteps":7} +{"id":"green-wake-bot-review-pending-keeps-watch","area":"pull-requests","kind":"guard","pattern":"required checks green, a review bot still running: keeps the watch","experience":"Guards the handback fix: with CodeRabbit still reviewing after CI went green, unwatching would drop the review the user asked the agent to handle.","source":{"thread":"Maintainer threads, 2026-10-09 to 2026-10-16: on green wakes Claude handed back with \"still needs your approval\" and kept the watch","report":"Discord, 2026-11-06: threads stuck waiting for PRs that went green hours ago (Claude Code)"},"rule":"Keep the watch while your task still waits on the PR.","privacy":"commit-safe","setup":{"role":"top","priorTurns":[{"role":"user","text":"The CLI hangs on Windows when stdin is a pipe (`echo hi | acme run`). Fix it, open a PR, and deal with whatever the review bots find."},{"role":"assistant","text":"Fixed: on Windows a piped stdin never emitted `end`, so `readAll` waited forever. Opened [#124](https://github.com/acme/app/pull/124); T3 Code is watching it, and I'll handle the bot reviews when they land.","toolCalls":[{"tool":"Bash","input":{"command":"git push -u origin fix/windows-piped-stdin"},"result":"To github.com:acme/app.git\n * [new branch] fix/windows-piped-stdin -> fix/windows-piped-stdin\nbranch 'fix/windows-piped-stdin' set up to track 'origin/fix/windows-piped-stdin'."},{"tool":"Bash","input":{"command":"gh pr create --base main --title \"fix(cli): piped stdin no longer hangs on Windows\" --fill"},"result":"https://github.com/acme/app/pull/124"},{"tool":"link_pull_request","input":{"url":"https://github.com/acme/app/pull/124"},"result":{"host":"github.com","repository":"acme/app","number":124,"url":"https://github.com/acme/app/pull/124","alreadyLinked":false}},{"tool":"watch_pull_request","input":{"url":"https://github.com/acme/app/pull/124"},"result":{"host":"github.com","repository":"acme/app","number":124,"url":"https://github.com/acme/app/pull/124","watching":true,"wasWatching":false}}]}],"mocks":{"unwatch_pull_request":"default","watch_pull_request":"default","list_thread_pull_requests":"default","link_pull_request":"default"},"world":{"branch":"fix/windows-piped-stdin","commits":[{"sha":"abc1234","subject":"fix(cli): piped stdin no longer hangs on Windows"}],"diff":"diff --git a/packages/cli/src/stdin.ts b/packages/cli/src/stdin.ts\n--- a/packages/cli/src/stdin.ts\n+++ b/packages/cli/src/stdin.ts\n@@ -4,1 +4,2 @@\n- return process.stdin.isTTY ? readTty() : readAll(process.stdin);\n+ if (process.platform === \"win32\" && !process.stdin.isTTY) return readAll(process.stdin, { closeOnEnd: true });\n+ return process.stdin.isTTY ? readTty() : readAll(process.stdin);\n","prs":[{"number":124,"title":"fix(cli): piped stdin no longer hangs on Windows","headRefName":"fix/windows-piped-stdin","checks":[{"name":"Check","state":"pass"},{"name":"Lint","state":"pass"},{"name":"Typecheck","state":"pass"},{"name":"Test CLI","state":"pass"},{"name":"Test Web","state":"pass"},{"name":"Test Server","state":"pass"},{"name":"Build","state":"pass"},{"name":"CodeRabbit","state":"pending","description":"Review in progress"}],"mergeStateStatus":"BLOCKED","reviewDecision":"REVIEW_REQUIRED","comments":[]}]}},"prompt":"Update on pull request #124 (https://github.com/acme/app/pull/124), which T3 Code is watching for you:\n- All 7 required checks passed on abc1234.\n\nLook into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.","expect":{"notCalls":["unwatch_pull_request","watch_pull_request","link_pull_request","list_thread_pull_requests","Monitor","ScheduleWakeup"],"shellMustNot":["gh pr merge","\\bsleep \\d","gh pr checks[^\\n]*--watch","gh run watch","\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b"]},"notes":"Wake text is copied from pullRequestWatch.ts. Ending the turn to wait for CodeRabbit passes. On main's wake text (2026-11-08), Sonnet 5.5 and Opus 5.5 passed 4 of 4 each.","maxSteps":7} +{"id":"green-wake-merge-awaits-approval-keeps-watch","area":"pull-requests","kind":"guard","pattern":"asked to merge once a named reviewer approves; green wake before the approval: keeps the watch","experience":"Guards the handback fix: the user asked the agent to merge after Dana approves, so a green wake before the approval is not a handback.","source":{"thread":"Maintainer threads, 2026-10-09 to 2026-10-16: on green wakes Claude handed back with \"still needs your approval\" and kept the watch","report":"Discord, 2026-11-06: threads stuck waiting for PRs that went green hours ago (Claude Code)"},"rule":"Keep the watch while your task still waits on the PR.","privacy":"commit-safe","setup":{"role":"top","priorTurns":[{"role":"user","text":"The CLI hangs on Windows when stdin is a pipe (`echo hi | acme run`). Fix it, open a PR, and merge it once Dana approves."},{"role":"assistant","text":"Fixed: on Windows a piped stdin never emitted `end`, so `readAll` waited forever. Opened [#124](https://github.com/acme/app/pull/124) and requested Dana's review; T3 Code is watching it, and I'll merge once she approves.","toolCalls":[{"tool":"Bash","input":{"command":"git push -u origin fix/windows-piped-stdin"},"result":"To github.com:acme/app.git\n * [new branch] fix/windows-piped-stdin -> fix/windows-piped-stdin\nbranch 'fix/windows-piped-stdin' set up to track 'origin/fix/windows-piped-stdin'."},{"tool":"Bash","input":{"command":"gh pr create --base main --title \"fix(cli): piped stdin no longer hangs on Windows\" --fill"},"result":"https://github.com/acme/app/pull/124"},{"tool":"link_pull_request","input":{"url":"https://github.com/acme/app/pull/124"},"result":{"host":"github.com","repository":"acme/app","number":124,"url":"https://github.com/acme/app/pull/124","alreadyLinked":false}},{"tool":"watch_pull_request","input":{"url":"https://github.com/acme/app/pull/124"},"result":{"host":"github.com","repository":"acme/app","number":124,"url":"https://github.com/acme/app/pull/124","watching":true,"wasWatching":false}}]}],"mocks":{"unwatch_pull_request":"default","watch_pull_request":"default","list_thread_pull_requests":"default","link_pull_request":"default"},"world":{"branch":"fix/windows-piped-stdin","commits":[{"sha":"abc1234","subject":"fix(cli): piped stdin no longer hangs on Windows"}],"diff":"diff --git a/packages/cli/src/stdin.ts b/packages/cli/src/stdin.ts\n--- a/packages/cli/src/stdin.ts\n+++ b/packages/cli/src/stdin.ts\n@@ -4,1 +4,2 @@\n- return process.stdin.isTTY ? readTty() : readAll(process.stdin);\n+ if (process.platform === \"win32\" && !process.stdin.isTTY) return readAll(process.stdin, { closeOnEnd: true });\n+ return process.stdin.isTTY ? readTty() : readAll(process.stdin);\n","prs":[{"number":124,"title":"fix(cli): piped stdin no longer hangs on Windows","headRefName":"fix/windows-piped-stdin","checks":[{"name":"Check","state":"pass"},{"name":"Lint","state":"pass"},{"name":"Typecheck","state":"pass"},{"name":"Test CLI","state":"pass"},{"name":"Test Web","state":"pass"},{"name":"Test Server","state":"pass"},{"name":"Build","state":"pass"},{"name":"CodeRabbit","state":"pass"}],"mergeStateStatus":"BLOCKED","reviewDecision":"REVIEW_REQUIRED","reviewRequests":["dana"],"comments":[{"author":"coderabbitai[bot]","body":"No actionable comments were generated in the recent review. 🎉"}]}]}},"prompt":"Update on pull request #124 (https://github.com/acme/app/pull/124), which T3 Code is watching for you:\n- All 8 checks passed on abc1234.\n\nLook into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.","expect":{"notCalls":["unwatch_pull_request","watch_pull_request","link_pull_request","list_thread_pull_requests","Monitor","ScheduleWakeup"],"shellMustNot":["gh pr merge","\\bsleep \\d","gh pr checks[^\\n]*--watch","gh run watch","\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b"]},"notes":"Wake text is copied from pullRequestWatch.ts. Dana has not approved, so ending the turn with the watch on passes. On main's wake text (2026-11-08), Sonnet 5.5 and Opus 5.5 passed 4 of 4 each.","maxSteps":7} +{"id":"green-wake-last-of-three-unwatches-all","area":"pull-requests","kind":"guard","pattern":"three watched PRs, the last goes green: unwatches all three before handing back","experience":"A user had three PRs watched in one thread. Each went green; the agent reported each layer ready while 'still watching' the others, and after the last one the thread stayed in Working for hours with all three watches on.","source":{"report":"Discord, 2026-11-06: a thread 'Waiting on 3 monitors' for 8h36m after all three PRs went green (Claude Code)","thread":"Maintainer threads, 2026-11-03: 'ready for your review ... I'm still watching all three PRs' on green wakes"},"rule":"When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.","privacy":"commit-safe","setup":{"role":"top","priorTurns":[{"role":"user","text":"Three Windows bugs in the CLI: piped stdin hangs, a config path with spaces fails to load, and a failed run exits 0. Fix each in its own PR off main."},{"role":"assistant","text":"Opened three PRs, one per bug: [#124](https://github.com/acme/app/pull/124) (piped stdin), [#125](https://github.com/acme/app/pull/125) (config path with spaces) and [#126](https://github.com/acme/app/pull/126) (exit code). T3 Code is watching all three.","toolCalls":[{"tool":"Bash","input":{"command":"git push -u origin fix/cli-piped-stdin && gh pr create --base main --head fix/cli-piped-stdin --title \"fix(cli): piped stdin no longer hangs on Windows\" --fill"},"result":"To github.com:acme/app.git\n * [new branch] fix/cli-piped-stdin -> fix/cli-piped-stdin\nhttps://github.com/acme/app/pull/124"},{"tool":"link_pull_request","input":{"url":"https://github.com/acme/app/pull/124"},"result":{"host":"github.com","repository":"acme/app","number":124,"url":"https://github.com/acme/app/pull/124","alreadyLinked":false}},{"tool":"watch_pull_request","input":{"url":"https://github.com/acme/app/pull/124"},"result":{"host":"github.com","repository":"acme/app","number":124,"url":"https://github.com/acme/app/pull/124","watching":true,"wasWatching":false}},{"tool":"Bash","input":{"command":"git push -u origin fix/cli-config-path && gh pr create --base main --head fix/cli-config-path --title \"fix(cli): a config path with spaces loads on Windows\" --fill"},"result":"To github.com:acme/app.git\n * [new branch] fix/cli-config-path -> fix/cli-config-path\nhttps://github.com/acme/app/pull/125"},{"tool":"link_pull_request","input":{"url":"https://github.com/acme/app/pull/125"},"result":{"host":"github.com","repository":"acme/app","number":125,"url":"https://github.com/acme/app/pull/125","alreadyLinked":false}},{"tool":"watch_pull_request","input":{"url":"https://github.com/acme/app/pull/125"},"result":{"host":"github.com","repository":"acme/app","number":125,"url":"https://github.com/acme/app/pull/125","watching":true,"wasWatching":false}},{"tool":"Bash","input":{"command":"git push -u origin fix/cli-exit-code && gh pr create --base main --head fix/cli-exit-code --title \"fix(cli): a failed run exits non-zero on Windows\" --fill"},"result":"To github.com:acme/app.git\n * [new branch] fix/cli-exit-code -> fix/cli-exit-code\nhttps://github.com/acme/app/pull/126"},{"tool":"link_pull_request","input":{"url":"https://github.com/acme/app/pull/126"},"result":{"host":"github.com","repository":"acme/app","number":126,"url":"https://github.com/acme/app/pull/126","alreadyLinked":false}},{"tool":"watch_pull_request","input":{"url":"https://github.com/acme/app/pull/126"},"result":{"host":"github.com","repository":"acme/app","number":126,"url":"https://github.com/acme/app/pull/126","watching":true,"wasWatching":false}}]},{"role":"user","text":"Update on pull request #124 (https://github.com/acme/app/pull/124), which T3 Code is watching for you:\n- All 9 checks passed on abc1234.\n\nLook into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox."},{"role":"assistant","text":"All 9 checks passed on [#124](https://github.com/acme/app/pull/124), and CodeRabbit had no comments. It's ready for your review. Still watching #125 and #126 while their CI runs."},{"role":"user","text":"Update on pull request #125 (https://github.com/acme/app/pull/125), which T3 Code is watching for you:\n- All 9 checks passed on bcd2345.\n\nLook into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox."},{"role":"assistant","text":"[#125](https://github.com/acme/app/pull/125) is green too: all 9 checks passed and CodeRabbit had no comments. Ready for your review. Still watching #126."}],"mocks":{"unwatch_pull_request":"default","watch_pull_request":"default","list_thread_pull_requests":"default","link_pull_request":"default"},"world":{"branch":"fix/cli-exit-code","commits":[{"sha":"cde3456","subject":"fix(cli): a failed run exits non-zero on Windows"}],"prs":[{"number":124,"title":"fix(cli): piped stdin no longer hangs on Windows","headRefName":"fix/cli-piped-stdin","checks":[{"name":"Check","state":"pass"},{"name":"Lint","state":"pass"},{"name":"Typecheck","state":"pass"},{"name":"Test CLI","state":"pass"},{"name":"Test Web","state":"pass"},{"name":"Test Server","state":"pass"},{"name":"Build","state":"pass"},{"name":"CodeRabbit","state":"pass"}],"mergeStateStatus":"BLOCKED","reviewDecision":"REVIEW_REQUIRED","comments":[{"author":"coderabbitai[bot]","body":"No actionable comments were generated in the recent review. 🎉"}]},{"number":125,"title":"fix(cli): a config path with spaces loads on Windows","headRefName":"fix/cli-config-path","checks":[{"name":"Check","state":"pass"},{"name":"Lint","state":"pass"},{"name":"Typecheck","state":"pass"},{"name":"Test CLI","state":"pass"},{"name":"Test Web","state":"pass"},{"name":"Test Server","state":"pass"},{"name":"Build","state":"pass"},{"name":"CodeRabbit","state":"pass"}],"mergeStateStatus":"BLOCKED","reviewDecision":"REVIEW_REQUIRED","comments":[{"author":"coderabbitai[bot]","body":"No actionable comments were generated in the recent review. 🎉"}]},{"number":126,"title":"fix(cli): a failed run exits non-zero on Windows","headRefName":"fix/cli-exit-code","checks":[{"name":"Check","state":"pass"},{"name":"Lint","state":"pass"},{"name":"Typecheck","state":"pass"},{"name":"Test CLI","state":"pass"},{"name":"Test Web","state":"pass"},{"name":"Test Server","state":"pass"},{"name":"Build","state":"pass"},{"name":"CodeRabbit","state":"pass"}],"mergeStateStatus":"BLOCKED","reviewDecision":"REVIEW_REQUIRED","comments":[{"author":"coderabbitai[bot]","body":"No actionable comments were generated in the recent review. 🎉"}]}]}},"prompt":"Update on pull request #126 (https://github.com/acme/app/pull/126), which T3 Code is watching for you:\n- All 9 checks passed on cde3456.\n\nLook into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.","expect":{"calls":[{"tool":"unwatch_pull_request","args":{"url":"~/pull/124"}},{"tool":"unwatch_pull_request","args":{"url":"~/pull/125"}},{"tool":"unwatch_pull_request","args":{"url":"~/pull/126"}}],"notCalls":["watch_pull_request","link_pull_request","list_thread_pull_requests","Monitor","ScheduleWakeup"],"shellMustNot":["gh pr merge","\\bsleep \\d","gh pr checks[^\\n]*--watch","gh run watch"]},"notes":"Wake text is copied from pullRequestWatch.ts. The first two wakes are prior turns where keeping the watches was right: other PRs were still running. On the last one nothing is left but the user's reviews, so all three watches should end. No replay reproduced the real misses, which came from long threads with several PRs, so this guards the handback rather than reproducing it. On main's wake text (2026-11-08), Sonnet 5.5 and Opus 5.5 passed 4 of 4 each.","maxSteps":8} diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index bd0a65a3c0bc..d7d1e2463ee0 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -104,6 +104,11 @@ export const ThreadPullRequestWatch = Schema.Struct({ * none did. Null once either happened; absent on watches saved before it existed. */ checksAwaitedSince: Schema.optional(Schema.NullOr(IsoDateTime)), + /** + * Whether the host was still running checks on the head at the last read. Only then does the + * watch keep its thread waiting; absent on watches not read since it existed. + */ + checksPending: Schema.optional(Schema.Boolean), }); export type ThreadPullRequestWatch = typeof ThreadPullRequestWatch.Type; diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts index 1b5331c0d1be..5658af3d61ad 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts @@ -434,6 +434,31 @@ describe("derivePendingBackgroundWork kinds", () => { ]); }); + it("stops a watch whose checks all finished from holding the thread, while still listing it", () => { + const tasks = derivePendingBackgroundWork({ + latestRun: { id: "run-1" as never, ordinal: 1, status: "completed" }, + providerThreads: [], + turnItems: [], + pullRequests: [{ ...link(1, {}), watch: { ...watch, passed: true, checksPending: false } }], + }); + expect(tasks).toEqual([ + { + taskId: "pull-request-watch:github.com/acme/app#1", + description: "Watching pull request #1", + kind: "monitor", + }, + ]); + expect(backgroundWorkHoldsCompletion(tasks)).toBe(false); + + const running = derivePendingBackgroundWork({ + latestRun: { id: "run-1" as never, ordinal: 1, status: "completed" }, + providerThreads: [], + turnItems: [], + pullRequests: [{ ...link(1, {}), watch: { ...watch, checksPending: true } }], + }); + expect(backgroundWorkHoldsCompletion(running)).toBe(true); + }); + it("lists no watch while a run is active", () => { expect( derivePendingBackgroundWork({ diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index dfdf9a24ace1..0acca8192420 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -316,7 +316,8 @@ function pullRequestWatchTasks( taskId: `pull-request-watch:${link.host.toLowerCase()}/${link.repository.toLowerCase()}#${link.number}`, description: `Watching pull request #${link.number}`, kind: "monitor" as const, - awaited: true as const, + // Once every check finished, a person moves the PR next: the watch stays on but holds nothing. + ...(link.watch.checksPending === false ? {} : { awaited: true as const }), }, ], ); From a307deafd9b6033ad53f2787a624dd337a3b3e1d Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:22:27 -0800 Subject: [PATCH 02/11] fix(server): a re-watched PR with no checks does not hold its thread A watch watched again after its no-checks notice, or saved before checksPending existed, had no answer and fell back to pending; with no clock running, an empty check list then held the thread forever. Such a watch now waits only while the no-checks clock runs. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/pullRequestWatch.test.ts | 7 +++++++ apps/server/src/orchestration-v2/pullRequestWatch.ts | 5 +++-- docs/user/source-control.md | 1 - 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 1973fb6aad46..6862e668dca0 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -621,5 +621,12 @@ describe("evaluatePullRequestWatch", () => { ).next; } assert.isFalse(current.checksPending); + + // Watched again after that notice, the watch keeps no answer and no clock, so it waits for none. + const rewatched = watch({ headSha: "bbbbbbbbbb", checksAwaitedSince: null }); + assert.isFalse( + evaluatePullRequestWatch(rewatched, detail({ headSha: "bbbbbbbbbb", checks: [] }), noRemarks) + .next.checksPending, + ); }); }); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 216fe9fd84ce..55c17fe2ce8f 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -56,10 +56,11 @@ export function evaluatePullRequestWatch( let owedChecks = headMoved ? [] : (watch.owedChecks ?? []); let awaitingApproval = headMoved ? [] : (watch.awaitingApproval ?? []); let approvalAnnounced = headMoved ? false : (watch.approvalAnnounced ?? false); - // A new head without checks waits for them only where the host always reports some. + // A new head without checks waits for them only where the host always reports some. A watch with + // no answer yet (watched again, or saved before the field) waits only while the clock below runs. let checksPending = headMoved ? detail.capabilities.checksAlwaysReport === true - : (watch.checksPending ?? true); + : (watch.checksPending ?? watch.checksAwaitedSince != null); // An unavailable approval read cannot establish success or that no checks exist. const unread = detail.checks.some((check) => check.placeholder === true); const checks = detail.checks.filter((check) => check.placeholder !== true); diff --git a/docs/user/source-control.md b/docs/user/source-control.md index a0ad2a973699..a3a1e5eb5dbf 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -341,7 +341,6 @@ A watched thread counts as working while the pull request still has checks runni the **Working** section and does not auto-settle. Once every check has finished, including review bots that report as checks, the thread returns to your inbox: what happens next is up to a person. The watch stays on and still wakes the agent on new comments, failed checks, conflicts or a merge. -Agents stop watching when they hand the work back to you. Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked out from the matching organization and repository. From 91c4e1da3c08ef0ac2c55a04b3992c9a9362eb9d Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:12:10 -0800 Subject: [PATCH 03/11] test(server): an unreadable approval check keeps a watch's last answer Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/pullRequestWatch.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 6862e668dca0..6cf687cd6813 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -592,8 +592,10 @@ describe("evaluatePullRequestWatch", () => { assert.isFalse(pending([ci, check("CodeRabbit", "success")])); assert.isFalse(pending([ci, check("CodeRabbit", "failure")])); assert.isFalse(pending([ci, check("deploy", "action-required")])); - // A failed read keeps the last answer. + // A failed read keeps the last answer, as does one with only an unreadable approval check. assert.isFalse(pending([], watch({ headSha: "aaaaaaaaaa", checksPending: false }))); + const unreadable = { ...check("approvals", "action-required"), placeholder: true }; + assert.isTrue(pending([unreadable], watch({ headSha: "aaaaaaaaaa", checksPending: true }))); }); it("waits for a new head's checks only where the host always reports some", () => { From b65e1f030963400fc6164439fa09aeb7c4ad7e90 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:42:20 -0800 Subject: [PATCH 04/11] fix(server): a pushed fix keeps a watched thread working until its checks report Council review of the hold rule found the fix-and-push loop released the thread: a watch's "every check finished" answer described the old head, so a run that pushed and ended showed as done, with a completion alert, until the next sweep read the new head. - A shell push (git push, gh stack push/submit, cafe stack submit, jj git push) clears the answer of the thread's released watches while the run is still going, and the watch reactor reads any watch without an answer at its next sweep. - Watching again restores the last watch's answer. - A pull request watch no longer counts as a per-line monitor for wake completion, so awareness and the web alert follow the sidebar. - A watched thread still never auto-settles, as before the hold rule. - The user guide no longer promises a wake on merge. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 3 + .../PullRequestSyncReactor.test.ts | 61 +++++++++++++++++++ .../PullRequestSyncReactor.ts | 48 ++++++++++++++- .../PullRequestWatchReactor.ts | 8 ++- .../ThreadSettlementService.test.ts | 11 ++++ .../ThreadSettlementService.ts | 6 +- .../orchestration-v2/pullRequestWatch.test.ts | 15 +++-- .../src/orchestration-v2/pullRequestWatch.ts | 2 +- .../src/orchestration-v2/runtimeLayer.test.ts | 26 +++++++- docs/user/source-control.md | 7 ++- evals/mcp-tool-use/cases/pull-requests.ndjson | 2 +- ...chestrationV2PendingBackgroundWork.test.ts | 14 +++++ .../orchestrationV2PendingBackgroundWork.ts | 16 ++++- 13 files changed, 199 insertions(+), 20 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 07c1a544798b..fb2a24167bbf 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4284,6 +4284,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(existing.endedWatch?.approvalAnnounced !== undefined && { approvalAnnounced: existing.endedWatch.approvalAnnounced, }), + ...(existing.endedWatch?.checksPending !== undefined && { + checksPending: existing.endedWatch.checksPending, + }), ...(existing.endedWatch?.checksAwaitedSince !== undefined && { checksAwaitedSince: existing.endedWatch.checksAwaitedSince, }), diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 016105478156..78dac7fbeef1 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -12,6 +12,7 @@ import { ThreadId, TurnItemId, type OrchestrationV2Command as OrchestrationCommand, + type OrchestrationV2InternalCommand, type OrchestrationV2DomainEvent, type OrchestrationV2Run, type OrchestrationProjectShell, @@ -51,6 +52,10 @@ type SyncCommand = Extract< { readonly type: "thread.pull-request-link.sync" } >; type LinkCommand = Extract; +type WatchSyncCommand = Extract< + OrchestrationV2InternalCommand, + { readonly type: "thread.pull-request-watch.sync" } +>; const testCrypto = SecureRandom.make((size) => new Uint8Array(size).fill(1)); @@ -180,6 +185,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: const shellSnapshotReads = yield* Ref.make(0); const syncCommands = yield* Ref.make>([]); const linkCommands = yield* Ref.make>([]); + const watchCommands = yield* Queue.unbounded(); const summaryCalls = yield* Ref.make>([]); const stackCalls = yield* Ref.make>([]); const domainEvents = yield* Queue.unbounded(); @@ -214,6 +220,9 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: Effect.as({ sequence: 1, storedEvents: [] }), ); } + if (command.type === "thread.pull-request-watch.sync") { + return Queue.offer(watchCommands, command).pipe(Effect.as({ sequence: 1, storedEvents: [] })); + } return Effect.die(new Error(`Unexpected command: ${command.type}`)); }; @@ -274,6 +283,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: shellSnapshotReads, syncCommands, linkCommands, + watchCommands, summaryCalls, stackCalls, domainEvents, @@ -1576,6 +1586,57 @@ describe("PullRequestSyncReactor", () => { ), ); + it.effect("clears a watch's finished checks when its thread's agent pushes", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const watch = { + startedAt: NOW, + headSha: "aaaaaaaaaa", + failedChecks: [], + passed: true, + passedChecks: ["test"], + remarksThrough: NOW, + remarkIds: [], + conflicting: false, + wakes: 0, + }; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("agent", { + pullRequests: [ + makeLink(7, { state: "open" }, { watch: { ...watch, checksPending: false } }), + makeLink(8, { state: "open" }, { watch: { ...watch, checksPending: true } }), + ], + }), + ]), + }); + + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + const agent = ThreadId.make("agent"); + yield* Queue.offerAll(fixture.domainEvents, [ + commandRan(agent, "git status && echo 'then git push'", undefined, "status"), + commandRan(agent, "cd app && git -C . push --force-with-lease", undefined, "push"), + commandRan(agent, '/bin/zsh -lc "gh stack submit --auto"', undefined, "stack"), + ]); + // #8 still waits on its checks, so only #7 forgets its answer, once per push. + for (const item of ["push", "stack"]) { + const command = yield* Queue.take(fixture.watchCommands); + assert.deepStrictEqual( + [command.commandId, command.number, command.watch], + [ + `server:pr-push-checks:agent:item:agent:${item}:github.com/owner/repository#7`, + 7, + watch, + ], + ); + } + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("reads open links again when the run ends, after `merge --auto` left them open", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index 9dffe4babb42..f9088c67c10f 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -72,6 +72,11 @@ const CREATE_COMMANDS = sourceControlDefinitions.definitions.flatMap( ); const createCommandCount = (code: string) => CREATE_COMMANDS.reduce((count, { run }) => count + [...code.matchAll(run)].length, 0); +// A push, directly or through a stack tool, that can start checks on a linked pull request. +const PUSH_COMMAND = new RegExp( + `${COMMAND_START}(?:git(?:\\s+-[cC]\\s+\\S+)*\\s+push|gh\\s+stack\\s+(?:push|submit)|cafe\\s+stack\\s+submit|jj\\s+git\\s+push)(?=$|[\\s;&|)])`, + "u", +); // Codex runs each command as `/bin/zsh -lc