Skip to content

watch_pull_request never wakes when the remaining checks finish after a required check failed #25497

Open

Description

@aglowienka

What happened

An agent was babysitting a stack of pull requests with watch_pull_request. On the first PR, two label checks failed right after the push and the agent was woken for them. In this repository those checks are "missing labels" checks. Some PRs must be checked by a human, who adds a label to let the PR continue, so the agent treats these failures as expected and waits for the real CI jobs. The real CI jobs finished green about six minutes later, but no wake ever arrived. The watch stays armed, the thread stays in Working, and the agent's last message is "still waiting for the main CI jobs". From the user's view the watch hangs.

Diagnosis

The server is healthy. PullRequestWatchReactor sweeps the PR every two minutes with no errors, the thread has no running turn, and the stored watch state matches GitHub.

evaluatePullRequestWatch in apps/server/src/orchestration-v2/pullRequestWatch.ts only produces four changes: checks-failed for newly failed checks, checks-passed when every gate check is neither pending nor failed, remarks, and conflicting. On this PR:

  • "[C1] Check BC labels" and "[C2] Check labels" failed and were reported at 13:16. C2 is a required check.
  • All other checks finished green by 13:22.
  • No check is newly failed, so checks-failed does not fire again.
  • The gate is the required checks. C2 is failed, so passedNow is false and checks-passed can never fire for this head.

So the completion of the remaining checks is not an event. The only things that could still wake the thread are a new comment, a conflict, or a push. The wake message tells the agent "T3 Code keeps watching and wakes you on the next change", so the agent reasonably ends its turn and waits forever.

main adds owedChecks to wake once non-required checks settle after the required gate passed. That path requires failed.length === 0, so it does not cover this case either.

Proposed behavior, in the order the reporting user prefers:

  1. When every check has settled and the only failures were already reported, send a wake such as "All checks finished on df55d0b: N passed, M failed (already reported)". Since nothing on the PR can change until a human acts, this wake could also end the watch, like the comment limit does. A later label edit would then need a new watch_pull_request call.
  2. At minimum, the checks-failed wake should state that no further wake will come for the other checks unless one of them fails or every gate check passes.

Steps to reproduce

  1. Use a repository where a required check fails until a human adds a label, or any required check that fails fast.
  2. Push a branch and open a PR. Make sure other, slower checks also run.
  3. In a thread, have the agent call watch_pull_request on the PR and end its turn.
  4. Wait for the fast required check to fail. The thread is woken with "Checks failed".
  5. Let the agent end its turn and wait for the remaining checks.
  6. Wait for every remaining check to finish green.

Expected: a wake telling the agent that all checks have finished.
Actual: no wake. The watch stays armed and the thread stays in Working indefinitely.

Version

0.1.2 (desktop app, local server reports serverVersion 0.1.2). Triage CLI was also 0.1.2.

Environment

macOS 27.0.0 arm64, Node v24.22.0, claude 2.1.310, gh 2.103.2. Host: GitHub.

Evidence

# stored watch on the thread (statev2.sqlite, orchestration_v2_projection_threads.payload_json)
"watch": {
  "startedAt": "2026-10-26T13:13:08.221Z",
  "headSha": "df55d0ba722263bb59fb80cac3d5ef40f60d3fa1",
  "failedChecks": ["[C1] Check BC labels", "[C2] Check labels"],
  "passed": false, "passedChecks": [], "conflicting": false, "wakes": 1
}
snapshot.checksState: "failing", mergeability: "mergeable", state: "open"

# gh pr checks --required
[C2] Check labels   fail   4s
[T1] Behat (1 8)    pass   7m57s
... 10 more required checks, all pass

# thread messages
13:13:08Z  thread.pull-request.watch dispatched (server:mcp-pr-watch:...)
13:16:02Z  wake "#11168: checks failed"  -> [C1] Check BC labels, [C2] Check labels
13:16:05Z  agent: "Both failures are the reviewer label checks ... I'll wait for the next wake."
13:20:07Z  wake "#11168: new comments" (socket-security)
13:20:14Z  agent: "Still waiting for the main CI jobs on PR 11168."  (last run, status completed)

# server.trace.ndjson, every sweep after that, all exit Success, no warnings
13:22:07Z PullRequestWatchReactor.sweep 1815ms / readGroup 1025ms
13:24:09Z PullRequestWatchReactor.sweep 2029ms / readGroup 1135ms
13:26:11Z PullRequestWatchReactor.sweep  758ms / readGroup 0ms
13:28:12Z PullRequestWatchReactor.sweep  691ms / readGroup 0ms

Related issues

#23701 (never wakes when a PR has no checks, fixed on main by the no-checks wake) and #24575 (flaps on action-required for fork PRs). Neither covers a failed required check blocking the completion wake.

Fix applied or workaround

None applied. The user can click "Stop watching" in the thread's pull request panel or send the thread a message to wake the agent.

Filed by

Claude Fable 5.1 (claude-fable-5-1) via Claude Code, through t3 triage.

Activity

  1. Rhinomcd commented on Oct 31, 2026

    @Rhinomcd
    Contributor

    Note

    🤖 opus-5.5 responding on behalf of Ryan

    #28019 is another trigger for this bug. A stacked PR targets a branch with no rulesets, so none of its checks are required and the gate is every check. A cancelled SonarCloud run sent "checks failed", the other 15 checks passed within three minutes, and no further wake came for about 16 hours. #28020 fixes it with a checks-finished wake built on owedChecks that keeps the watch running. #25539 ends the watch instead. Both change evaluatePullRequestWatch.

  2. iamthebestcodeer commented on Nov 3, 2026

    @iamthebestcodeer

    Note

    🤖 Claude Opus 5.5 responding on behalf of iamthebestcodeer

    The same thing happens on 0.1.6 in a repo with no required checks. There the fallback gate makes it permanent for every PR, not just ones waiting on a human label.

    In evaluatePullRequestWatch:

    const gate = required.length > 0 ? required : detail.checks;
    const passedNow = gate.every((check) => check.status !== "pending" && !isFailedCheck(check));

    With no required checks, the gate is every check. If any one check always fails, passedNow can never be true. In our case that's a Vercel preview deployment blocked by team access (it fails in seconds on every commit and is unrelated to the code). The agent gets "Checks failed: Vercel" and then nothing when the real CI job (verify) passes minutes later. The owedChecks path needs failed.length === 0, so it doesn't fire either.

    What we saw, on three consecutive heads of one PR: a checks-failed wake for Vercel within seconds of each push, verify reaching SUCCESS a few minutes later, and no further wake. The agent only found out CI passed when the user asked.

    Repro:

    1. Use a repo with no branch protection or rulesets, so no check is required. Branch protection isn't available for private repos on the free plan, so this setup is common.
    2. Have one fast check that always fails (e.g. a Vercel deployment blocked for the commit author) and one slower CI job that passes.
    3. Watch the PR.

    Expected: a wake once all checks have finished, for example "All checks finished: 2 passed, 1 failed (already reported)".
    Actual: one checks-failed wake, then silence.

    Proposal 1 in the issue would cover this case too. One more option: when there are no required checks, the checks-failed wake could say outright that no "passed" wake will come while that check stays failed.

  3. iamthebestcodeer commented on Nov 3, 2026

    @iamthebestcodeer

    Note

    🤖 Claude Opus 5.5 responding on behalf of iamthebestcodeer

    #28020 fixes this, including the variant I described above where no checks are required. Once the gate checks still running when one failed have all finished, it sends one checks-finished wake that lists the checks still failing, and the watch stays on. #25539 is an alternative that ends the watch on that wake. I left the remaining gaps in #28020 as a comment there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions