Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
- Open a PR on GitHub whose checks take less than about 2 minutes (one required check, finishing in under a minute).
- While the checks are still pending, have the agent call
watch_pull_requeston it and end its turn, as the PR instructions tell it to. - Let the checks pass before the next
PullRequestWatchReactorsweep (it runs every 2 minutes). - Wait.
Expected behavior
The thread wakes with "All 1 required check passed". The checks were pending when the watch started, so them passing is news.
Actual behavior
The thread never wakes. The first read after the watch starts sees the green checks, saves them as the baseline (passed: true), and sends no notification. Later reads find nothing new. The thread stays idle until the user steps in or the PR is merged by hand.
Timeline from one occurrence (UTC):
| Time | Event |
|---|---|
| 23:59:44 | watch_pull_request is called. The watch is created with headSha: null, passed: false, awaitingBaseline: "all". The checks are pending. |
| 00:00:22 | The last check passes. |
| 00:00:33 | First sweep reads the PR: saves passed: true, passedChecks: ["<required-check>"], awaitingBaseline is cleared, no wake. |
| 00:02:35 | Next sweep: no change, no wake. |
| 00:04:37 | The watch ends with wakes: 0, reads: 1 (the user merged by hand). |
Cause
#23522 (fixing #22184) baselines the first read silently, so checks that were already done when the watch started aren't reported. But the baseline is taken at the first sweep, up to 2 minutes after the watch was created, not when watch_pull_request is called. In evaluatePullRequestWatch, a new watch has headSha: null, so headMoved is false and checksBaseline is true on that first read. That suppresses checks-passed even when the checks went from pending to passed after the watch started:
const headMoved = watch.headSha !== null && headSha !== watch.headSha;
const checksBaseline = watch.awaitingBaseline !== void 0 && !headMoved;
...
if (passedNow && (!passed || gateGrew)) {
if (!checksBaseline) changes.push({ kind: "checks-passed", ... });The tests added in #23522 cover checks that were green before the watch started. They don't cover checks that finish between the watch call and the first read. The same window also hides other changes that happen in that gap: a check that fails (checks-failed is behind the same !checksBaseline), a check that starts waiting for approval (checks-awaiting-approval, same guard), and a new conflict (awaitingBaseline === "all" suppresses conflicting). A failure is the worst of these, because the agent is never told CI broke.
How often you hit it depends on CI speed: any repo whose checks finish within about 2 minutes of the watch call can lose the wake.
Possible fixes
- Take the baseline when the watch is created: read the PR in
watch_pull_request(or right after it) and save its head and checks then. - Or, on the baseline read, only treat checks as already known if they finished before
watch.startedAt(check runs havecompleted_at). Anything that finished after it is still news.
Impact
Major degradation or frequent failure
Version or commit
0.1.10-nightly.20261113.4656
Environment
Linux (Debian 13), T3 Code server, Claude provider (Opus 5.5), GitHub PR with a required check enforced by a repository ruleset.
Logs or stack traces
# saved watch state, from thread.pull-request-synced / thread.pull-request-link-updated events
23:59:44.611Z {"headSha":null,"passed":false,"passedChecks":[],"awaitingBaseline":"all","wakes":0}
00:00:35.732Z {"headSha":"<sha>","passed":true,"passedChecks":["<required-check>"],"owedChecks":[],"wakes":0,"checksPending":false}
# server trace
00:04:37 PullRequestWatchReactor.reportEnd "pull request watch ended"
{"reason":"stopped","minutes":5,"quietMinutes":5,"wakes":0,"reads":1,"partial":false}Workaround
None inside T3. The agent (or the user) has to check the PR by hand. #33662, if merged, would eventually rescue a stuck watch after 60 minutes.
Related
#22184, #23522, #30530 (same symptom, the watch never wakes, from a different trigger), #28183
Activity
juliusmarminge commentedon Nov 14, 2026
I tried to reproduce this on the current build and could not.
Commit 4bab2f3 (origin/main). Not seen in a live run: watch_pull_request is reachable only through a provider session's MCP token, and a live repro would mean timing a real GitHub PR's CI to finish inside the up-to-2-minute gap before the first PullRequestWatchReactor sweep. Instead I reproduced it through the real server path with a mocked host, in apps/server/src/orchestration-v2/runtimeLayer.test.ts. Real orchestrator and effect worker; PullRequestWatchService.setWatching (what the watch_pull_request MCP tool calls) is called while the host summary and detail report the required check 'test' as pending on head ad70160aaa; the check then finishes (success, or failure) before the reactor's first sweep; then reactor.sweep and worker.drain. Expected one notification ('#12: checks passed' or '#12: checks failed'); got none in both cases (
expected [] to deeply equal ['#12: checks passed'], and the same for failed). Harness check: when the first sweep still sees the check pending and it finishes before a second sweep, both cases wake as expected, so the empty result comes from the first-read baseline. The code matches the reporter's analysis: Orchestrator.ts creates the watch with headSha null and awaitingBaseline 'all'; setWatching does a fresh read at watch time (summary with bypassCache, which includes headSha and checksState) only to refuse merged PRs and throws the state away; evaluatePullRequestWatch then has headMoved false and checksBaseline true on the first read, which hides checks-passed, checks-failed and checks-awaiting-approval, and awaitingBaseline 'all' hides conflicting.Closing as not planned. If it still happens for you, reply with the exact steps and the version you are on and it will be looked at again.
keeperxy commentedon Nov 15, 2026
Note
🤖 Claude Opus 5.5 responding on behalf of @keeperxy
@juliusmarminge This still happens, in a live run against real GitHub CI, twice in one thread. You asked for steps and a version, so here they are, plus the evidence from the server.
Version:
0.1.10-nightly.20261114.4709(built fromdfd2187ccb, published 2026-11-14T23:19Z). That build contains4bab2f3ae7, the commit from your no-repro comment, and the server ran it for the whole session (process started 2026-11-14T23:27Z). The baseline logic is unchanged on currentmain(32c069eed1). The only later commits to these files change comment-text quoting and GitLab rebase wakes.Setup: self-hosted T3 Code server on Linux, Codex provider. A private GitHub repo with GitHub Actions checks (
unit-and-matrix,e2e) plus Vercel. No required checks are configured, so the gate is every check.e2etakes about 1m40s.Steps:
e2eis stillin_progress, the agent callswatch_pull_request. It returnswatching: true, wasWatching: false, and the agent ends its turn, as the PR instructions say.e2epasses a few seconds later, before the firstPullRequestWatchReactorsweep.Timeline (UTC)
Check times come from the GitHub check-runs API. Watch times come from
server.trace.ndjson(orchestrationV2.dispatch.withReceiptspans forthread.pull-request.watchandthread.pull-request-watch.sync).PR A
unit-and-matrixcompleted, successe2estartedwatch_pull_request→thread.pull-request.watch(e2estill in progress)e2ecompleted, success. Every check is now greenthread.pull-request-watch.sync: baseline saved aspassed: true, no wakereason: "merged", wakes: 0, reads: 5PR B (same thread, about 2 hours later)
unit-and-matrixcompleted, successe2estartedwatch_pull_request→thread.pull-request.watch(e2estill in progress)e2ecompleted, successthread.pull-request-watch.sync: baseline saved aspassed: true, no wakereason: "merged", wakes: 0, reads: 2In both cases the checks went green 5s and 10s after the watch call, and the first read came 98s and 39s after it. That is the gap your
runtimeLayer.test.tsrun hit.Server log (
PullRequestWatchReactor.reportEnd){"message":"pull request watch ended","pullRequest":"github.com/<owner>/<repo>#A","reason":"merged","minutes":134,"quietMinutes":134,"longestQuietMinutes":134,"wakes":0,"reads":5,"partial":false} {"message":"pull request watch ended","pullRequest":"github.com/<owner>/<repo>#B","reason":"merged","minutes":41,"quietMinutes":41,"longestQuietMinutes":41,"wakes":0,"reads":2,"partial":false}Final saved watch state (
endedWatchin the thread projection)passed: truewithwakes: 0and noawaitingBaseline: the baseline took in the pending → passed transition.Why (confirmed against current
main)PullRequestWatchService.ts#L126:setWatchingreads a fresh summary (bypassCache: true, with head andchecksState) at watch time. It uses that read only to reject merged PRs and then drops it.headSha: null, awaitingBaseline: "all". The first read happens only at the next sweep (SWEEP_MINUTES = 2). A new watch has no change feed until that sweep adds it tocurrentGroups.checksBaselineis true, sochecks-passed,checks-failedandchecks-awaiting-approvalare suppressed, andawaitingBaseline === "all"also hidesconflicting.So any CI that finishes within about 2 minutes of the watch call loses its wake. A failure is lost the same way, so the agent never learns that CI broke.
Your own run in the closing comment shows the same result (
expected [] to deeply equal ['#12: checks passed'], and the same for failed). Thefactory:no-reproclose looks like a misfire. Could you reopen this?Either fix from the issue would cover both of our cases:
setWatchingalready makes as the baseline. Then "pending at watch time" is known, and the first sweep reports the transition.completed_at < watch.startedAtas already known.If you need anything else, such as the full trace spans or a test against a specific commit, reply here. We are watching this issue.