Skip to content

[Bug]: PR watch misses successful same-head CI reruns that start and finish between polls #34212

Closed as not planned

Description

@ewodev

Before submitting

  • I searched existing issues; related reports and their differences are listed below.
  • I included a deterministic reproduction and live timing evidence.

Area

apps/server

Summary

A watched GitHub PR received its first checks-passed wake. After review, the agent marked the PR ready, which triggered another CI run on the same head SHA. The agent ended its turn explicitly awaiting that run. The new run started and succeeded entirely between two watcher sweeps. T3 subsequently read the final state but never woke the thread again.

The result is polling-phase dependent: if the evaluator observes the intermediate pending state it emits another completion wake; if it misses pending, the same new run's completion is suppressed.

Steps to reproduce

Live sequence

  1. Use a GitHub Actions workflow triggered on both pull_request.opened and pull_request.ready_for_review, with a quick check such as validate.
  2. Open a draft PR and call watch_pull_request. Let its initial checks pass and wait for the checks-passed notification.
  3. Without pushing a commit, mark the PR ready. Have the agent end its turn awaiting the newly triggered CI run, keeping the existing watch active.
  4. The new run must start and finish between two watcher reads. A slower runner that lets T3 observe pending can mask the problem.
  5. Observe through the next full detail read. The snapshot becomes passing, but no completion notification or new agent run is created.

The live incident below was an existing authorized workflow, not a newly created public test PR. It was investigated using read-only GitHub queries, persisted watch events and server traces. No additional message, rewatch or runtime restart was injected into the measured interval.

Deterministic reproduction (no server, provider, runner or npm install required)

Requires a Node version exposing node:module.stripTypeScriptTypes (tested with Node 24.21.0). Save this as repro.mjs:

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { stripTypeScriptTypes } from 'node:module';

const source = readFileSync(process.argv[2], 'utf8');
// Fixtures never enter the DateTime-dependent no-checks branch.
const js = stripTypeScriptTypes(source).replace(
  'import * as DateTime from "effect/DateTime";', ''
);
const { evaluatePullRequestWatch: evaluate } = await import(
  'data:text/javascript;base64,' + Buffer.from(js).toString('base64')
);
const watch = {
  startedAt: '2026-11-11T00:00:00Z', headSha: 'same-head',
  failedChecks: [], passed: false, passedChecks: [], owedChecks: [],
  remarksThrough: '2026-11-11T00:00:00Z', remarkIds: [],
  conflicting: false, wakes: 0,
};
const results = [];
for (const required of [false, true]) {
  const detail = (status, run, headSha = 'same-head') => ({
    headSha, capabilities: { checksAlwaysReport: false },
    mergeability: 'mergeable', viewer: 'agent-user',
    author: { login: 'agent-user' },
    checks: [{
      name: 'validate', status, required, description: null,
      url: `https://ci.example/runs/${run}/jobs/validate`,
    }],
  });
  const first = evaluate(watch, detail('success', 'A'), [], 0);
  assert.equal(first.changes[0].kind, 'checks-passed');
  const sameRead = evaluate(first.next, detail('success', 'A'), [], 0);
  assert.deepEqual(sameRead.changes, []);
  const missed = evaluate(first.next, detail('success', 'B'), [], 0);
  assert.deepEqual(missed.changes, []); // Confirms the observed missing wake.
  const pending = evaluate(first.next, detail('pending', 'B'), [], 0);
  const control = evaluate(pending.next, detail('success', 'B'), [], 0);
  assert.equal(control.changes[0].kind, 'checks-passed');
  const pushed = evaluate(first.next, detail('success', 'B', 'new-head'), [], 0);
  assert.equal(pushed.changes[0].kind, 'checks-passed');
  results.push({
    required, initial: first.changes, sameRunReread: sameRead.changes,
    newRunWithoutPendingRead: missed.changes,
    newRunWithPendingRead: control.changes, newHead: pushed.changes,
  });
  if (process.argv.includes('--expect-wake')) {
    assert.ok(missed.changes.some(x => x.kind === 'checks-passed'),
      'A new completed CI run was not reported because pending was not observed');
  }
}
console.log(JSON.stringify(results, null, 2));

Download the actual release source and run:

curl -fsSL https://raw.githubusercontent.com/pingdotgg/t3code/bf469bb19669671d0df2efda74ed73867a7137c0/apps/server/src/orchestration-v2/pullRequestWatch.ts -o pullRequestWatch.ts
node repro.mjs pullRequestWatch.ts
# Explicit expected-behavior assertion, currently exits 1:
node repro.mjs pullRequestWatch.ts --expect-wake

The script strips TypeScript types and removes the external DateTime import. The fixtures never enter its no-checks branch. It runs the actual upstream evaluator; it does not reimplement the decision logic. The same missing-wake behavior was separately reproduced against the evaluator extracted from the installed binary.

Results for both an explicitly required gate and the fallback all-checks gate:

initial successful run A                     => checks-passed
reread of the same successful run A          => [] (correct deduplication)
new successful run B, pending not observed   => [] (missing completion)
new run B, pending observed before success   => checks-passed
successful run after a new head SHA          => checks-passed

Run B has the same check name but a different run URL. --expect-wake fails with:

AssertionError: A new completed CI run was not reported because pending was not observed

Expected behavior

An agent explicitly awaiting a newly started CI cycle should receive its completion even when the cycle finishes between polls. Repeated reads of the same completed run should remain deduplicated. At minimum, whether completion is reported should not depend on accidentally observing pending between two otherwise identical run histories.

This is not a request to merge automatically or to treat a wake as proof that merge/deploy policy is satisfied.

Actual behavior

The watch remains active, the PR snapshot updates to passing, and no new agent turn starts. The final agent message continues to say it is awaiting CI even though that CI has finished.

Diagnosis

The installed reactor uses a two-minute sweep interval. evaluatePullRequestWatch deduplicates success using the head SHA, passed, gate names and owed checks. It does not distinguish successive completed CI runs through their URL/ID/attempt.

With the same SHA and gate names, previously saved passed=true, and owedChecks=[], a second successful run does not satisfy the new-pass condition. A pending read resets passed, explaining the contrasting control result. No new gate names appear in this case, so the late-required-gate handling does not help.

A shorter interval may reduce frequency but cannot eliminate this race. Any fix should preserve same-run deduplication while recognizing a new awaited CI cycle; run identity may need to survive the provider/fingerprint/evaluator boundary.

Impact

Major degradation or frequent failure: an agent waiting for an authorized follow-up can remain idle indefinitely until a user supplies another message.

Version or commit

Live incident and installed evaluator: 0.1.8, release commit bf469bb19669671d0df2efda74ed73867a7137c0.

Deterministic public-source reproduction also confirmed against main at dea84724384fcc68ef802e7db8440d9abaf0ef26. This is a function-level check of main, not a full end-to-end run of that build.

Source: release evaluator, release reactor.

Environment

Linux ARM64 server, kernel 7.0.0-1013-oracle; T3 Code 0.1.8; Codex provider, gpt-6.1-sol; GitHub Actions. Local standalone reproduction: Node 24.21.0. No UI-specific behavior was tested.

Logs or stack traces

Sanitized live evidence; all times UTC on 2026-11-11:

06:07:54.000  Initial CI succeeds.
06:08:40.462  Watch enabled.
06:10:11.222  Initial checks-passed wake delivered; agent verifies first run.
06:15:28.454  Review completion wakes agent.
06:16:12.513  Watch sweep starts; new CI does not yet exist.
06:16:12.994  Watch sweep finishes.
              Agent marks draft PR ready, keeping head SHA unchanged.
06:16:29.000  New CI run created.
06:16:43.485  Agent turn ends, explicitly waiting for new CI.
06:16:45.779  PR snapshot updates: draft=false, checksState=pending.
              Saved watch still has passed=true and owedChecks=[].
06:17:18.000  New successful check completes.
06:17:19.000  New CI run completes successfully.
06:18:12.995  Next watch sweep starts.
06:18:13.428  Watch readGroup performs full detail read.
06:18:14.083  Sweep finishes successfully.
06:18:14.644  PR snapshot updates: checksState=passing.
              Watch remains active: passed=true, owedChecks=[].
06:56+        Observer confirms no new notification/run; no active run,
              no pending user request; GitHub reports PR CLEAN.

The snapshot synchronization and the watch evaluator use separate paths: a persisted pending snapshot does not mean that the periodic evaluator observed a pending gate. The actual watch sweeps bracket the complete new CI run.

Private PR URLs, thread IDs, account names and home paths are intentionally omitted. The synthetic reproduction above contains no organization-specific data.

Related issues

Workaround

Manually prompt the waiting thread to verify the current run and continue. This supplies a manual wake but does not fix monitoring. No runtime patch, database write or service restart was performed for this investigation.

Activity

  1. juliusmarminge commented on Nov 11, 2026

    @juliusmarminge
    Member

    I tried to reproduce this on the current build and could not.

    Not run end to end: that needs a GitHub PR whose same-head CI rerun starts and finishes inside one 2-minute watcher sweep, and I did not create a test repo or PR for that. On origin/main 362ef9a I added a case to apps/server/src/orchestration-v2/pullRequestWatch.test.ts that calls the evaluator (evaluatePullRequestWatch) directly. A first read with check 'validate' passed in run 1 gives checks-passed. A reread of run 1 gives no change, which is correct. A read where the same check passed in run 2 (same head, new URL), with no pending read in between, gives no change: the missing wake. The same result holds with the check required and not required. vp test run src/orchestration-v2/pullRequestWatch.test.ts: 1 failed (only the new assertion), 23 passed. Pushed as factory/repro-34212 (1351502).

    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.

  2. AndryHTC commented on Nov 11, 2026

    @AndryHTC

    Follow-up after an independent review of the report:

    The polling-dependent behavior is confirmed by the evaluator reproduction. The desired resolution should be stated separately from that observation: recognizing a newly completed CI run is our proposal; #32276 proposes pass-once-per-head instead. Whichever policy is chosen, observing an intermediate pending read should not accidentally decide whether a completion is news.

    One correction to the standalone script: guard the observed-behavior assertion so --expect-wake can also pass after a fix:

    if (!process.argv.includes('--expect-wake')) assert.deepEqual(missed.changes, []);

    The fixture evaluates the initial pass before reusing first.next, so its second read uses a non-empty passedChecks list. The empty list in the initial input is not the state used for reproducing the missed wake.

    The live case was on 0.1.8 (bf469bb19669671d0df2efda74ed73867a7137c0). Its two recorded watcher sweeps bracket the complete new run. Selected trace fields, UTC on November 11:

    PullRequestWatchReactor.sweep      start=06:16:12.513103Z durationMs=481 exit=Success
    PullRequestWatchReactor.readGroup  start=06:16:12.994465Z durationMs=0 exit=Success
    new CI run                       created=06:16:29Z completed=06:17:19Z success
    agent waiting turn               completed=06:16:43.485Z
    PullRequestWatchReactor.sweep      start=06:18:12.995186Z durationMs=1088 exit=Success
    PullRequestWatchReactor.readGroup  start=06:18:13.428222Z durationMs=654 exit=Success
    PR snapshot persisted            at=06:18:14.644Z checksState=passing
    

    The watch remained active and no subsequent notification or run existed at observation. This is evidence from an existing live workflow, not a newly executed end-to-end test of current main. The standalone main check is function-level only. Frequency was not measured; the impact is a stalled continuation when a new CI cycle completes between reads. Current main also uses workflow URLs for approval tracking; the URL-insensitive claim concerns successful-run deduplication specifically.

    I am studying a focused fix and a reactor/persisted-notification regression. No running installation or production state has been modified.

  3. AndryHTC commented on Nov 11, 2026

    @AndryHTC

    Following up on #34212 (comment) with the completed reactor regression and proposed fix, rather than just the evaluator reproduction.

    Affected live version: T3 Code 0.1.8, release commit bf469bb19669671d0df2efda74ed73867a7137c0, Linux ARM64, GitHub Actions, Codex provider.

    Exact live sequence:

    1. Open a draft PR whose Actions workflow runs on pull_request.opened and pull_request.ready_for_review.
    2. Enable watch_pull_request, let the first CI run pass and receive its completion wake.
    3. Mark the PR ready without changing its head SHA, triggering another run with the same check names. The agent ends its turn awaiting that run, keeping the watch active.
    4. That run starts and completes entirely between watcher reads. Do not inject another prompt or restart the watch.
    5. The next detail read sees successful checks, but no new notification or agent turn is created.

    The existing live incident meets that timing condition: the new run was created at 06:16:29 UTC and completed successfully at 06:17:19 on November 11; the agent ended its waiting turn at 06:16:43.485. Watcher reads bracketed the run at 06:16:12.994 and 06:18:13.428. The later snapshot was passing, the watch remained active, and no follow-up turn existed at observation. The selected server trace fields are in my preceding comment: #34212 (comment).

    Additional evidence now available: #34221, current head 7731959f5e8b6e71415dec8b4db108eb7d9c5562, extends the existing runtimeLayer.test.ts reactor lifecycle case. It supplies two successful same-head runs with different job URLs and no intermediate pending detail. On the original implementation the assertion fails because only one notification is persisted; with the proposed fix the second notification is persisted exactly once. It also checks rereads, reactor restart, unwatch/rewatch, required/fallback gates and a new head. This executes the reactor, orchestrator and persistence with a mocked source-control host, not just the pure evaluator.

    vp test run apps/server/src/orchestration-v2/runtimeLayer.test.ts   -t 'wakes once for a state it reported'

    The final candidate passes local check:changed: 170 tests in 7 files, typecheck of 37 affected/dependent packages, format, lint, knip and GraphQL checks. Review feedback on duplicate check names, owed advisory completion and GitLab fallback baselines was corrected; an independent Opus 5.5 medium code review found no blockers. Known non-blocking GitLab limitations are documented in the PR.

    This is not a claim that we ran a fresh real-GitHub end-to-end experiment on current main, or installed the patched runtime. That validation remains outstanding. The proposed per-run completion policy also remains subject to maintainer agreement, especially alongside #32276.

    Could you reconsider/reopen this report in light of the recorded live case and the reactor-level regression? The proposed fix and its verification are available in #34221.

  4. AndryHTC commented on Nov 11, 2026

    @AndryHTC

    @juliusmarminge would you have a look?

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