Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/server/src/orchestration-v2/PullRequestWatchReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc
left.remarkIds.join("\n") === right.remarkIds.join("\n") &&
left.conflicting === right.conflicting &&
left.wakes === right.wakes &&
left.checksAwaitedSince === right.checksAwaitedSince
left.checksAwaitedSince === right.checksAwaitedSince &&
left.dequeuedThrough === right.dequeuedThrough
);
}

Expand Down
26 changes: 26 additions & 0 deletions apps/server/src/orchestration-v2/pullRequestWatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,32 @@ describe("evaluatePullRequestWatch", () => {
);
});

it("reports a merge queue removal once, never the agent's own or one before the watch", () => {
const removal = { reason: "failed-checks", at: "2026-10-02T12:20:00Z", actor: null } as const;
const dequeued = detail({ mergeQueueRemoval: removal });
const tired = watch({ headSha: "aaaaaaaaaa", wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1 });
const first = evaluatePullRequestWatch(tired, dequeued, noRemarks);
assert.deepEqual(first.changes, [{ kind: "dequeued", removal }]);
assert.equal(first.next.wakes, 0);
assert.deepEqual(evaluatePullRequestWatch(first.next, dequeued, noRemarks).changes, []);
const { text, notification } = pullRequestWatchMessage({
number: 1,
url: "https://github.com/o/r/pull/1",
baseBranch: "main",
headSha: "aaaaaaaaaa",
report: first,
});
assert.include(text, "- The merge queue removed it because checks failed on the merge group.");
assert.equal(notification.outcome, "failed");

const older = { ...removal, at: "2026-10-02T11:00:00Z" };
const before = evaluatePullRequestWatch(watch(), detail({ mergeQueueRemoval: older }), []);
assert.deepEqual(before.changes, []);
const own = { reason: "manual", at: removal.at, actor: "Agent-User" } as const;
const pulled = evaluatePullRequestWatch(watch(), detail({ mergeQueueRemoval: own }), []);
assert.deepEqual(pulled.changes, []);
});

it("does not spend the comment wake limit on check results", () => {
const tired = watch({ headSha: "aaaaaaaaaa", wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1 });
const result = evaluatePullRequestWatch(
Expand Down
51 changes: 48 additions & 3 deletions apps/server/src/orchestration-v2/pullRequestWatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
PullRequestCheck,
PullRequestComment,
PullRequestDetail,
PullRequestMergeQueueRemoval,
ThreadPullRequestWatch,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
Expand All @@ -19,7 +20,8 @@ export type PullRequestWatchChange =
| { readonly kind: "checks-passed"; readonly count: number; readonly required: boolean }
| { readonly kind: "remarks"; readonly remarks: ReadonlyArray<PullRequestComment> }
| { readonly kind: "no-checks"; readonly minutes: number }
| { readonly kind: "conflicting" };
| { readonly kind: "conflicting" }
| { readonly kind: "dequeued"; readonly removal: PullRequestMergeQueueRemoval };

export interface PullRequestWatchReport {
readonly changes: ReadonlyArray<PullRequestWatchChange>;
Expand All @@ -35,7 +37,13 @@ export function evaluatePullRequestWatch(
watch: ThreadPullRequestWatch,
detail: Pick<
PullRequestDetail,
"capabilities" | "headSha" | "checks" | "mergeability" | "viewer" | "author"
| "capabilities"
| "headSha"
| "checks"
| "mergeability"
| "viewer"
| "author"
| "mergeQueueRemoval"
>,
remarks: ReadonlyArray<PullRequestComment> | null,
now: number,
Expand Down Expand Up @@ -136,6 +144,16 @@ export function evaluatePullRequestWatch(
const conflicting =
detail.mergeability === "unknown" ? toldConflict : detail.mergeability === "conflicting";

// Queue checks run on a merge group commit, so a removal is the only sign they failed.
const removal = detail.mergeQueueRemoval;
const dequeued =
removal != null &&
Date.parse(removal.at) > Date.parse(watch.dequeuedThrough ?? watch.startedAt);
if (dequeued && (removal.actor === null || removal.actor.toLowerCase() !== own)) {
changes.push({ kind: "dequeued", removal });
}
const dequeuedThrough = dequeued ? removal.at : watch.dequeuedThrough;

const commentsOnly = changes.length > 0 && changes.every((change) => change.kind === "remarks");
const progress = headMoved || (changes.length > 0 && !commentsOnly);
const wakes = (progress ? 0 : watch.wakes) + (commentsOnly ? 1 : 0);
Expand All @@ -153,6 +171,7 @@ export function evaluatePullRequestWatch(
conflicting,
wakes,
checksAwaitedSince,
...(dequeuedThrough === undefined ? {} : { dequeuedThrough }),
},
exhausted: commentsOnly && wakes >= PULL_REQUEST_WATCH_WAKE_LIMIT,
};
Expand Down Expand Up @@ -210,6 +229,28 @@ function changeLines(
];
case "conflicting":
return [`- The branch now conflicts with ${context.baseBranch}.`];
case "dequeued":
return [
`- ${dequeuedLine(change.removal, context.baseBranch)} It will not merge until it is queued again.`,
];
}
}

const QUEUE_CHECKS =
"Those checks ran on a temporary merge group commit, not on the pull request's head, so they are not listed here.";

function dequeuedLine(removal: PullRequestMergeQueueRemoval, baseBranch: string): string {
switch (removal.reason) {
case "failed-checks":
return `The merge queue removed it because checks failed on the merge group. ${QUEUE_CHECKS}`;
case "checks-timed-out":
return `The merge queue removed it because checks on the merge group did not finish in time. ${QUEUE_CHECKS}`;
case "merge-conflict":
return `The merge queue removed it because it conflicts with ${baseBranch} or with pull requests ahead of it in the queue.`;
case "manual":
return `${removal.actor ?? "Someone"} removed it from the merge queue.`;
case "other":
return "The merge queue removed it.";
}
}

Expand All @@ -219,6 +260,7 @@ const SUMMARY: Record<PullRequestWatchChange["kind"], string> = {
remarks: "new comments",
"no-checks": "no checks reported",
conflicting: "merge conflict",
dequeued: "left merge queue",
};

export function pullRequestWatchMessage(input: {
Expand All @@ -242,7 +284,10 @@ export function pullRequestWatchMessage(input: {
: "Look into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.",
].join("\n");
const failed = changes.some(
(change) => change.kind === "checks-failed" || change.kind === "conflicting",
(change) =>
change.kind === "checks-failed" ||
change.kind === "conflicting" ||
(change.kind === "dequeued" && change.removal.reason !== "manual"),
);
const summary = changes.map((change) => SUMMARY[change.kind]);
if (exhausted) summary.push("stopped watching");
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/pullRequest/PullRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1856,6 +1856,9 @@ export const make = Effect.gen(function* () {
...(changeRequest.workflowApprovalsRequired === undefined
? {}
: { workflowApprovalsRequired: changeRequest.workflowApprovalsRequired }),
...(changeRequest.mergeQueueRemoval === undefined
? {}
: { mergeQueueRemoval: changeRequest.mergeQueueRemoval }),
})),
),
),
Expand Down
9 changes: 5 additions & 4 deletions docs/user/source-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,10 +303,11 @@ review is terminal. An open or unsynced link keeps it active.

Ask the agent to watch, monitor, or babysit a pull request and it calls `watch_pull_request`. While
the thread is active, the server checks the pull request every two minutes and wakes the agent when a
check fails, the required checks pass, someone else comments or reviews, or the branch starts to
conflict. Threads in a project that watch the same pull request share one check. On GitHub, a check
first asks whether anything changed and reads the pull request only when it did, which keeps
watching inside GitHub's rate limit. Comments from your own account do not wake it. Watching ends
check fails, the required checks pass, someone else comments or reviews, the branch starts to
conflict, or GitHub's merge queue drops it without merging. Threads in a project that watch the
same pull request share one check. On GitHub, a check first asks whether anything changed and
reads the pull request only when it did, which keeps watching inside GitHub's rate limit.
Comments and merge queue removals from your own account do not wake it. Watching ends
when the pull request merges or closes, after 10 wakes in a row that bring only comments, after 8
failed reads in a row, or when you press Stop on the thread. A rate limit only pauses watching.
Settling or archiving a thread also ends all its watches. Unsettle the thread before starting a new
Expand Down
1 change: 1 addition & 0 deletions evals/mcp-tool-use/cases/pull-requests.ndjson
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@
{"id": "link-one-pr", "area": "pull-requests", "privacy": "commit-safe", "kind": "control", "pattern": "single link stays single", "experience": "Linking one named PR is ordinary work.", "setup": {"role": "top", "context": "No pull requests are linked to this thread.", "world": {"repo": "acme/app", "branch": "main"}}, "prompt": "link https://github.com/acme/app/pull/130 here", "expect": {"calls": [{"tool": "link_pull_request", "args": {"url": "~/pull/130"}}], "notCalls": ["link_pull_requests"]}, "maxSteps": 6, "source": {"thread": "Synthetic control for batch link"}}
{"id": "schedule-one-time-date", "area": "pull-requests", "kind": "bad", "pattern": "one-off date scheduled as a daily task", "experience": "Theo asked whether a scheduled task can run at a specific date and time. schedule_task only had daily times, intervals, and webhooks, so a one-off request became a daily task.", "source": {"report": "maintainer request, 2026-10-31: scheduled tasks could not run at one date and time"}, "rule": "schedule: {type:'once',runAt} for one date and time", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-4", "title": "Cut 2.0 release branch", "schedule": {"type": "once", "runAt": "2026-11-03T09:00:00.000Z"}, "enabled": true, "nextRunAt": "2026-11-03T09:00:00.000Z"}}}, "prompt": "On November 3rd at 9am UTC, start a new thread that cuts the 2.0 release branch from main in acme/app. Only that one time.", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "once", "schedule.runAt": "~11-03T09:00"}}], "notCalls": ["delegate_task", "run_scheduled_task_now"]}, "notes": "Mock result shape is an approximation."}
{"id": "schedule-fresh-session-per-run", "area": "pull-requests", "kind": "guard", "pattern": "recurring task that should not accumulate context", "experience": "A recurring triage task kept one thread so the user could read its history, but each run carried every earlier run's context into the next.", "rule": "schedule_task `freshSession: true` keeps runs in one thread while each run starts a fresh session.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Today is Monday 2026-11-02, 10:00 local time.", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-7", "title": "Triage new issues", "enabled": true, "projectId": "project-1", "boundThreadId": null, "freshSession": true, "schedule": {"type": "fixed_time", "timeOfDay": "09:00"}, "nextRunAt": "2026-11-03T09:00:00-08:00", "lastRunStatus": "never"}}}, "prompt": "Every morning at 9, triage new issues in acme/app. Keep all the runs in one thread so I can scroll back, but each run should start clean without remembering the previous days.", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "fixed_time", "freshSession": true}}], "notCalls": ["delegate_task", "run_scheduled_task_now"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-11-03: automations in one thread with separate provider sessions"}, "notes": "schedule-one-off-tomorrow is the control: an ordinary schedule must not set freshSession."}
{"id":"wake-merge-queue-removed-investigates","area":"pull-requests","kind":"guard","pattern":"on a merge queue removal wake: find the failed merge group run","experience":"A PR dropped out of the GitHub merge queue because its merge group checks failed, and the watch never woke the agent, so the thread sat idle with an open PR. The new wake says the head checks are not the ones that failed; the agent must look at the merge group run instead of reading the passing head checks as good to go.","rule":"Look into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again.","privacy":"commit-safe","setup":{"role":"top","priorTurns":[{"role":"user","text":"File PR. Babysit. Merge when you are confident it's good to go."},{"role":"assistant","text":"Opened [#124](https://github.com/acme/app/pull/124). T3 Code is watching it; I will merge once checks pass and review findings are clear.","toolCalls":[{"tool":"Bash","input":{"command":"git push -u origin fix/socket-retry"},"result":"To github.com:acme/app.git\n * [new branch] fix/socket-retry -> fix/socket-retry\nbranch 'fix/socket-retry' set up to track 'origin/fix/socket-retry'."},{"tool":"Bash","input":{"command":"gh pr create --base main --title \"fix(web): retry the socket once before showing an error\" --fill"},"result":"https://github.com/acme/app/pull/124"},{"tool":"link_pull_request","input":{"url":"https://github.com/acme/app/pull/124"},"result":{"host":"github.com","repository":"acme/app","number":124,"url":"https://github.com/acme/app/pull/124","alreadyLinked":false}},{"tool":"watch_pull_request","input":{"url":"https://github.com/acme/app/pull/124"},"result":{"host":"github.com","repository":"acme/app","number":124,"url":"https://github.com/acme/app/pull/124","watching":true,"wasWatching":false}}]}],"mocks":{"watch_pull_request":"default","list_thread_pull_requests":"default","link_pull_request":"default","unwatch_pull_request":"default"},"world":{"branch":"fix/socket-retry","commits":[{"sha":"abc1234","subject":"fix(web): retry the socket once before showing an error"}],"diff":"diff --git a/apps/web/src/socket.ts b/apps/web/src/socket.ts\n--- a/apps/web/src/socket.ts\n+++ b/apps/web/src/socket.ts\n@@ -10,1 +10,2 @@\n- if (error) return showError(error);\n+ if (error && !retried) return reconnect({ retried: true });\n+ if (error) return showError(error);\n","prs":[{"number":124,"title":"fix(web): retry the socket once before showing an error","headRefName":"fix/socket-retry","checks":[{"name":"Check","state":"pass"},{"name":"Lint","state":"pass"},{"name":"Typecheck","state":"pass"},{"name":"Test Web","state":"pass"}],"mergeStateStatus":"CLEAN","isInMergeQueue":false,"mergeQueue":{"removedReason":"failed_checks","mergeGroupRun":{"workflow":"CI","event":"merge_group","headBranch":"gh-readonly-queue/main/pr-124-9f8e7d6c5b4a39281706f5e4d3c2b1a098765432","conclusion":"failure","failedJob":"Test Web","failure":"apps/web/src/socket.test.ts > reconnects once: expected reconnect to be called 1 time, got 2. main gained a second reconnect call in #121, which this branch duplicates."}}}]}},"prompt":"Update on pull request #124 (https://github.com/acme/app/pull/124), which T3 Code is watching for you:\n- The merge queue removed it because checks failed on the merge group. Those checks ran on a temporary merge group commit, not on the pull request's head, so they are not listed here. It will not merge until it is queued again.\n\nLook into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.","expect":{"notCalls":["watch_pull_request","list_thread_pull_requests","link_pull_request","link_pull_requests","Monitor","ScheduleWakeup"],"shellMust":["gh run (list|view)|merge_group|gh-readonly-queue|actions/runs|check-runs"],"shellMustNot":["\\bsleep \\d","gh pr checks[^\\n]*--watch","gh run watch","\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b","pr-wait"]},"notes":"Source: the merge queue investigation for PullRequestWatchReactor (queue checks run on a gh-readonly-queue commit, so the head checks stay green). Wake text is rendered by pullRequestWatchMessage for a failed_checks removal. Fixing and pushing, or reporting the failure, both pass; re-queueing without looking at the merge group run fails shellMust."}
22 changes: 22 additions & 0 deletions packages/contracts/src/pullRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as HttpServerResponse from "effect/http/HttpServerResponse";

import {
ForwardCompatibleArray,
ForwardCompatibleOptional,
IsoDateTime,
NonNegativeInt,
PositiveInt,
Expand Down Expand Up @@ -595,6 +596,22 @@ export const PullRequestReportStateInput = Schema.Struct({
});
export type PullRequestReportStateInput = typeof PullRequestReportStateInput.Type;

export const PullRequestMergeQueueRemovalReason = Schema.Literals([
"failed-checks",
"checks-timed-out",
"merge-conflict",
"manual",
"other",
]);
export type PullRequestMergeQueueRemovalReason = typeof PullRequestMergeQueueRemovalReason.Type;

export const PullRequestMergeQueueRemoval = Schema.Struct({
reason: PullRequestMergeQueueRemovalReason,
at: IsoDateTime,
actor: Schema.NullOr(TrimmedNonEmptyString),
});
export type PullRequestMergeQueueRemoval = typeof PullRequestMergeQueueRemoval.Type;

export const PullRequestDetail = Schema.Struct({
provider: SourceControlProviderKind,
capabilities: PullRequestCapabilities,
Expand Down Expand Up @@ -635,6 +652,11 @@ export const PullRequestDetail = Schema.Struct({
autoMergeMethod: Schema.optional(PullRequestMergeMethod),
workflowApprovalsRequired: Schema.optional(NonNegativeInt),
maintainerCanModify: Schema.optional(Schema.Boolean),
/**
* The latest time the merge queue dropped the pull request without merging it. Null when it
* never has; absent where the host reports no merge queue.
*/
mergeQueueRemoval: ForwardCompatibleOptional(Schema.NullOr(PullRequestMergeQueueRemoval)),
});
export type PullRequestDetail = typeof PullRequestDetail.Type;

Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/threadPullRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export const ThreadPullRequestWatch = Schema.Struct({
* none did. Null once either happened; absent on watches saved before it existed.
*/
checksAwaitedSince: Schema.optional(Schema.NullOr(IsoDateTime)),
/** The latest merge queue removal already reported; absent means none since startedAt. */
dequeuedThrough: Schema.optional(IsoDateTime),
});
export type ThreadPullRequestWatch = typeof ThreadPullRequestWatch.Type;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
PullRequestListState,
PullRequestMergeCapabilities,
PullRequestMergeMethod,
PullRequestMergeQueueRemoval,
PullRequestMergeability,
PullRequestOmittedFileStat,
PullRequestReaction,
Expand Down Expand Up @@ -210,6 +211,7 @@ export interface ProviderChangeRequestDetail extends ProviderChangeRequest {
readonly autoMergeEnabled?: boolean;
readonly autoMergeMethod?: PullRequestMergeMethod;
readonly workflowApprovalsRequired?: number;
readonly mergeQueueRemoval?: PullRequestMergeQueueRemoval | null;
}

export interface ProviderChangeRequestActivity {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const coreResponse = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({
headRef: null,
author: null,
autoMergeRequest: null,
lastMergeQueueRemoval: { nodes: [] },
headRefName: "feature",
baseRefName: "main",
headRefOid: "abc123",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const coreFields = {
canEditMaintainerEdits: false,
reviewRequestTeams: [],
checksTruncated: false,
mergeQueueRemoval: null,
comparison: { behindBy: 0, viewerCanUpdate: true },
viewerAccess: {
canWrite: true,
Expand Down
Loading
Loading