Repository navigation
[Bug]: watch_pull_request wakes only on failures and all-required-passed, not on other check state changes #23245
Replies: 2 comments
|
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. |
|
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 On Linux with the Codex provider, running
We inspected the running compiled server and the matching branches in public source pinned at
The closed-PR handler at line 345 supplies a wake. 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 Tested with Node 22.23.3. Save as Observed output: The script exits 1 for the reproduced omission and 2 for a setup/source-loading error. Node may also print its experimental Runnable reproductionconst 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. |
Uh oh!
There was an error while loading. Please reload this page.
Before submitting
Area
apps/server
Summary
watch_pull_requestwakes 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 frompendingtosuccess. 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
pending(for example ado-not-mergehold).watch_pull_request.pendingtosuccess.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):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
failedChecksandpassed. 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
All reactions