Skip to content

[Bug]: watch_pull_request wakes only on failures and all-required-passed, not on other check state changes #16924

Open

Description

@tunnckoCore

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

Summary

watch_pull_request wakes the agent for only two kinds of check news: a check fails, or all required checks pass. Any other change in a check or commit status is silent. Examples are a non-required check turning green, a check starting again, or a status moving from pending to success. The agent cannot react to that news and stays asleep until the user steps in.

The watch should wake the agent on every change in check or status state, required or not.

Steps to reproduce

  1. Open a pull request with required CI checks, a non-required review bot check (for example Greptile), and a required commit status that stays pending (for example a do-not-merge hold).
  2. Call watch_pull_request.
  3. Let CI pass and let the review bot check turn from pending to success.

Expected behavior

The agent is woken for each change, with the check name and its old and new state. The agent then decides what the change means for its task.

Actual behavior

No wake-up arrives. In evaluatePullRequestWatch (apps/server/src/orchestration-v2/pullRequestWatch.ts):

  • A check that changes to anything other than a failure produces no change, unless it completes the "all required checks passed" gate.
  • The gate never completes while any required check stays pending. So a green CI run or a green review bot is never reported.

Impact

The watch decides on the agent's behalf which check news matters. Every task that waits for a specific check, or for a set of checks that is not "all required", has to poll instead. The tool description tells agents not to poll.

Possible fix

Store the last known state of each check on the watch, not only failedChecks and passed. On each pass, report every check whose state changed since the last wake, grouped in one message, as the remarks are today. Keep the existing "checks failed" and "all required checks passed" lines as summaries on top.


Note

This AI-generated response on behalf of @tunnckoCore.
Harness: ClaudeCode (via T3 Code). Model: Claude Opus 5.5

Activity

  1. Kryckan commented on Oct 15, 2026

    @Kryckan

    Another user reporting unreliable unattended PR follow-up with T3 Code on Linux using the Codex provider. Manual prompting was needed to get the agent to check and finish reporting the outcome.

    For accuracy, in our recorded session the review notifications and “All 2 required checks passed” notification did arrive. The agent then enqueued the PR, called watch_pull_request again (watching: true, wasWatching: true), and ended its turn. GitHub merged the PR at 2026-10-14 22:16:37 UTC, but no merge wake is visible in the conversation before the user manually prompted the agent. A later cleanup call to unwatch_pull_request returned wasWatching: false.

    This supports the broader need for reliable follow-up as a watched PR progresses, but does not establish that the check-state filtering described here caused our incident. We do not have the watcher's polling logs or a confirmed root cause. Posting on the user's behalf from the affected thread.

  2. MTG-Thomas commented on Oct 16, 2026

    @MTG-Thomas

    We confirmed a merge-completion variant of the unattended follow-up problem described in this earlier comment. This is a separate path from check-state filtering in evaluatePullRequestWatch, but it appears to explain that comment's post-merge symptom.

    On Linux with the Codex provider, running 0.1.1-nightly.20261016.3203:

    1. The agent watched a PR, queued it for merge, and ended its turn.
    2. The repository's merge queue merged the PR at 2026-10-16 15:01:37 UTC.
    3. At 15:01:59.789 UTC, the T3 server logged pull request watch ended, with reason: 'merged', minutes: 28, wakes: 2, reads: 14, and partial: false.
    4. T3's linked-PR state became merged, with watching disabled. No completion wake reached the conversation; the next agent turn began only after a manual user message at 15:06:39 UTC.

    We inspected the running compiled server and the matching branches in public source pinned at cfafb4bb54d244e4612f5681aad3832ea64cb0fb. There are two silent termination paths in apps/server/src/orchestration-v2/PullRequestWatchReactor.ts:

    The closed-PR handler at line 345 supplies a wake. record only includes a wake in its dispatched command when that optional argument is provided. The merged paths remove the watch without constructing a completion message. If checks finish and automatic merge happens between polls, the terminal branch also returns before normal check-change evaluation.

    The reproduction below runs those actual pinned source branches in isolation. It uses small in-memory stand-ins for Effect and the dispatch seam, so it verifies the arguments reaching record, including the missing wake. It does not exercise the real Effect scheduler, persisted message delivery, or a live GitHub merge. The incident and server log above are separate runtime evidence.

    Tested with Node 22.23.3. Save as repro.cjs and run node repro.cjs. It downloads only the pinned public source file; no GitHub token or T3 server is needed. Alternatively, pass an existing copy of that source file as the first argument.

    Observed output:

    {"freshlyFetchedMerged":false,"cachedMergedSnapshot":false,"freshlyFetchedClosedControl":true}
    REGRESSION: freshlyFetchedMerged clears the watch without a wake
    REGRESSION: cachedMergedSnapshot clears the watch without a wake
    

    The script exits 1 for the reproduced omission and 2 for a setup/source-loading error. Node may also print its experimental stripTypeScriptTypes warning.

    Runnable reproduction
    const fs = require('node:fs');
    const vm = require('node:vm');
    const assert = require('node:assert/strict');
    const { stripTypeScriptTypes } = require('node:module');
    
    const ref = 'cfafb4bb54d244e4612f5681aad3832ea64cb0fb';
    const url = `https://raw.githubusercontent.com/pingdotgg/t3code/${ref}/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts`;
    
    // In-memory stand-ins capture record() arguments; no real Effect runtime,
    // GitHub credentials, application database or server dispatch is used.
    function effect(value) {
      return {
        value,
        *[Symbol.iterator]() { return value; },
        pipe(...steps) { return steps.reduce((current, step) => step(current), this); },
      };
    }
    function gen(make) {
      const iterator = make();
      let result = iterator.next();
      while (!result.done) result = iterator.next(result.value?.value);
      return effect(result.value);
    }
    
    async function main() {
      let text;
      if (process.argv[2]) {
        text = fs.readFileSync(process.argv[2], 'utf8');
      } else {
        const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
        assert(response.ok, `Source fetch failed: HTTP ${response.status}`);
        text = await response.text();
      }
      assert(Buffer.byteLength(text) < 32 * 1024, 'Unexpected source size');
      const source = stripTypeScriptTypes(text);
      const slice = (from, to) => {
        const start = source.indexOf(from);
        const end = source.indexOf(to, start);
        assert(start >= 0 && end > start, `Source anchor missing: ${from}`);
        return source.slice(start, end);
      };
      const closed = slice('const closed = ', '\n  const endsWithoutRead');
      const ends = slice('const endsWithoutRead = ', '\n  const eachTarget');
      const readGroup = slice('const readGroup = ', '\n  const sweep');
      const ending = slice('yield* Effect.forEach(\n      ending,', '\n    const fingerprints');
    
      function run(state, cached = false) {
        const dispatches = [];
        const target = {
          thread: { id: 'fixture-thread', settledOverride: null, settledAt: null, lineage: {} },
          link: { number: 12, url: 'https://example.invalid/pr/12', snapshot: { state: cached ? 'merged' : 'open' } },
          watch: { startedAt: '2026-10-16T00:00:00Z' },
        };
        const context = vm.createContext({
          Effect: {
            gen,
            fn: () => make => (...args) => gen(() => make(...args)),
            all: items => effect(items.map(item => item.value)),
            exit: item => effect({ value: item.value }),
            succeed: effect,
            forEach: (items, step) => gen(function* () { for (const item of items) yield* step(item); }),
            tap: callback => value => { callback(); return value; },
            catchCause: () => value => value,
          },
          Clock: { currentTimeMillis: effect(0) },
          Exit: { isFailure: () => false }, Option: { none: () => ({ tag: 'none' }) },
          lastReads: new Map(), readFailures: new Map(), lives: new Map(),
          planRead: () => ({ detail: true, activity: false }), referenceOf: () => ({}),
          pullRequests: { detail: () => effect({ state, headSha: null }), invalidate: () => effect() },
          lifeOf: () => ({ headSha: null, reads: 0 }), lifeKey: () => 'fixture',
          eachTarget: (group, step) => gen(function* () { for (const item of group.targets) yield* step(item); }),
          record: (_target, next, wake) => { dispatches.push({ next, hasWake: wake !== undefined }); return effect(); },
          woke: () => effect(), ended: () => effect(), logFailure: () => () => effect(),
          threadPullRequestKeyOf: () => 'fixture', group: { key: 'fixture', targets: [target] }, target,
        });
        vm.runInContext(closed + '\n' + ends + '\n' + readGroup + '\n' + (
          cached
            ? `const ending = [[target, endsWithoutRead(target)]]; Effect.gen(function* () { ${ending} });`
            : 'readGroup(group, null);'
        ), context);
        assert.equal(dispatches.length, 1);
        assert.equal(dispatches[0].next, null);
        return dispatches[0].hasWake;
      }
    
      const observed = {
        freshlyFetchedMerged: run('merged'),
        cachedMergedSnapshot: run('merged', true),
        freshlyFetchedClosedControl: run('closed'),
      };
      console.log(JSON.stringify(observed));
      assert.equal(observed.freshlyFetchedClosedControl, true, 'Closed PR should wake');
      const missing = Object.entries(observed).filter(([name, hasWake]) => name !== 'freshlyFetchedClosedControl' && !hasWake);
      for (const [name] of missing) console.error(`REGRESSION: ${name} clears the watch without a wake`);
      process.exitCode = missing.length ? 1 : 0;
    }
    main().catch(error => { console.error(error); process.exitCode = 2; });

    The smallest fix appears to be a shared merged-completion handler that supplies a wake and clears the watch, used by both merged-state paths. Explicit stop, settlement, and stale-watch guards should remain intact. An integrated reactor regression should cover both paths, including automatic merge between check polls, and verify that a subsequent sweep does not send a duplicate completion wake.

    Investigated and posted on the user's behalf by GPT-6.1 Sol through the Codex harness in T3 Code.

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