From c3a1760dee4206dec058d1b08e808f6046d4563e Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 25 Oct 2026 14:21:00 -0500 Subject: [PATCH 1/9] feat(server): PR watches can wake only for the changes an agent asks for watch_pull_request takes an optional events list (comment, review, checks_failed, checks_passed, no_checks, conflict). The watch stores it and evaluatePullRequestWatch drops changes it does not list, while still recording them so a wider filter later does not replay old news. Omitting it keeps waking on everything. A closed PR or a watch T3 ends still wakes. Co-Authored-By: Claude Opus 5.5 --- .../toolkits/pullRequests/handlers.test.ts | 2 + .../src/mcp/toolkits/pullRequests/handlers.ts | 4 +- .../src/mcp/toolkits/pullRequests/tools.ts | 14 ++- .../src/orchestration-v2/Orchestrator.ts | 23 +++- .../orchestration-v2/pullRequestWatch.test.ts | 22 ++++ .../src/orchestration-v2/pullRequestWatch.ts | 34 +++++- .../src/orchestration-v2/runtimeLayer.test.ts | 101 ++++++++++++++++++ .../src/provider/agentContextBudget.json | 2 +- docs/user/source-control.md | 4 +- packages/contracts/src/orchestrationV2.ts | 3 + packages/contracts/src/threadPullRequest.ts | 17 +++ 11 files changed, 218 insertions(+), 8 deletions(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 88691423c0ef..1e59f6745a07 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -271,6 +271,7 @@ describe("pull request toolkit handlers", () => { const harness = yield* makeHarness(); const result = yield* harness.call("watch_pull_request", { url: "https://github.com/t3tools/t3code/pull/9", + events: ["review", "checks_failed"], }); expect(result).toMatchObject({ number: 9, watching: false, wasWatching: false }); expect(yield* Ref.get(harness.commands)).toMatchObject([ @@ -279,6 +280,7 @@ describe("pull request toolkit handlers", () => { number: 9, watching: true, link: { url: "https://github.com/t3tools/t3code/pull/9", source: "agent" }, + events: ["review", "checks_failed"], }, ]); }), diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index a4cd07b89fb4..fd6f86709918 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -47,6 +47,7 @@ import { PullRequestsToolkit, type ThreadPullRequestEntry, CHILD_PULL_REQUEST_REPORT_NEXT, + type WatchPullRequestInput, } from "./tools.ts"; interface ResolvedTarget { @@ -261,7 +262,7 @@ const make = Effect.gen(function* () { : Effect.fail(new Failure({ cause })); const setWatching = Effect.fn("PullRequestsToolkit.setWatching")(function* ( - input: PullRequestTargetInput, + input: WatchPullRequestInput, watching: boolean, ) { const thread = yield* requireThread(PullRequestWatchFailedError, input.threadId); @@ -288,6 +289,7 @@ const make = Effect.gen(function* () { number: target.number, watching, ...(watching ? { link: { url: target.url, source: "agent" as const } } : {}), + ...(input.events === undefined ? {} : { events: input.events }), }) .pipe(Effect.catchCause(dispatchFailure(PullRequestWatchFailedError))); const after = yield* requireThread(PullRequestWatchFailedError, thread.id); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 866580fc5e02..3a6f8782d24a 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -7,6 +7,7 @@ import { PullRequestReviewGuideRun, PullRequestState, ThreadPullRequestLinkSource, + ThreadPullRequestWatchEvents, TrimmedNonEmptyString, ThreadId, } from "@t3tools/contracts"; @@ -59,6 +60,17 @@ export const PullRequestTargetInput = Schema.Struct({ }); export type PullRequestTargetInput = typeof PullRequestTargetInput.Type; +export const WatchPullRequestInput = Schema.Struct({ + ...PullRequestTargetInput.fields, + events: Schema.optional( + ThreadPullRequestWatchEvents.annotate({ + description: + "Only these changes wake you. Default: all. A closed PR or a watch T3 ends still wakes you.", + }), + ), +}); +export type WatchPullRequestInput = typeof WatchPullRequestInput.Type; + export class PullRequestUrlInvalidError extends Schema.TaggedError()( "PullRequestUrlInvalidError", {}, @@ -317,7 +329,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { const WatchPullRequestTool = Tool.make("watch_pull_request", { description: "Watch an open PR for this thread, linking it first if needed. T3 wakes you on failed checks, passed required checks, new comments or reviews, or conflicts. Handle old comments first. Only later ones wake you. After this call, end your turn. The watch stays on after each wake, so do not call this again on a wake. A wake is not a merge decision. Check readiness yourself.", - parameters: PullRequestTargetInput, + parameters: WatchPullRequestInput, success: WatchPullRequestResult, failure: PullRequestToolError, dependencies, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 0a5e56d98d63..2cbe5f81d63a 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -35,6 +35,7 @@ import { type OrchestrationV2ServerCommand, type ThreadPullRequestLink, type ThreadPullRequestWatch, + type ThreadPullRequestWatchEvents, type OrchestrationV2AppThread, type OrchestrationV2ContextHandoff, type OrchestrationV2ContextSourcePoint, @@ -654,6 +655,18 @@ function hasLiveRun(projection: Pick): return projection.runs.some(isLiveRun); } +/** Sorted and deduplicated, so the same filter in another order is the same watch. */ +const sortedWatchEvents = (events: ThreadPullRequestWatchEvents | undefined) => + events && [...new Set(events)].toSorted(); + +function withWatchEvents( + watch: ThreadPullRequestWatch, + events: ThreadPullRequestWatchEvents | undefined, +): ThreadPullRequestWatch { + const { events: _events, ...rest } = watch; + return events === undefined ? rest : { ...rest, events }; +} + function withPullRequestWatch( link: ThreadPullRequestLink, watch: ThreadPullRequestWatch | undefined, @@ -3724,7 +3737,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : linked; const existing = links.find(visible); if (existing === undefined) return thread; - const watch = + const observed = command.type === "thread.pull-request-watch.sync" ? existing.watch?.startedAt === command.startedAt ? (command.watch ?? undefined) @@ -3756,6 +3769,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio remarkIds: [], wakes: 0, }); + // The filter is the watcher's choice, not read state, so a sync never changes it. + const events = sortedWatchEvents( + command.type === "thread.pull-request.watch" ? command.events : existing.watch?.events, + ); + const watch = + observed === undefined || observed.events?.join() === events?.join() + ? observed + : withWatchEvents(observed, events); if (watch === existing.watch && links === linked) return thread; const final = command.type === "thread.pull-request-watch.sync" && watch === undefined diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 0b6070f36466..fb90657ac216 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -78,6 +78,28 @@ const remark = ( const noRemarks: ReadonlyArray = []; describe("evaluatePullRequestWatch", () => { + it("wakes only for the events a watch lists, and still records the rest", () => { + const green = detail({ checks: [check("lint", "success")], mergeability: "conflicting" }); + const comment = { + ...remark("reviewer", "2026-10-02T12:01:00.000Z"), + kind: "issue-comment" as const, + }; + const review = { ...remark("reviewer", "2026-10-02T12:02:00.000Z"), kind: "review" as const }; + const remarks: ReadonlyArray = [comment, review]; + assert.deepEqual( + evaluatePullRequestWatch(watch(), green, noRemarks).changes.map((change) => change.kind), + ["checks-passed", "conflicting"], + ); + + const quiet = evaluatePullRequestWatch(watch({ events: ["comment"] }), green, noRemarks); + assert.deepEqual(quiet.changes, []); + assert.include(quiet.next, { passed: true, conflicting: true, wakes: 0 }); + + const reviews = evaluatePullRequestWatch(watch({ events: ["review"] }), green, remarks); + assert.deepEqual(reviews.changes, [{ kind: "remarks", remarks: [review] }]); + assert.equal(reviews.next.remarksThrough, review.createdAt); + }); + it("reports each failure at once, even while another check never finishes", () => { const bot = check("CodeRabbit", "pending"); const first = detail({ checks: [check("lint", "failure"), check("test", "pending"), bot] }); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 033125a6d6c9..51b2db63b6e4 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -4,6 +4,8 @@ import type { PullRequestComment, PullRequestDetail, ThreadPullRequestWatch, + ThreadPullRequestWatchEvent, + ThreadPullRequestWatchEvents, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -31,6 +33,28 @@ export interface PullRequestWatchReport { const isFailedCheck = (check: PullRequestCheck) => check.status === "failure" || check.status === "cancelled" || check.status === "action-required"; +const CHANGE_EVENT: Record< + Exclude, + ThreadPullRequestWatchEvent +> = { + "checks-failed": "checks_failed", + "checks-passed": "checks_passed", + "no-checks": "no_checks", + conflicting: "conflict", +}; + +const remarkEvent = (remark: PullRequestComment): ThreadPullRequestWatchEvent => + remark.kind === "issue-comment" ? "comment" : "review"; + +function wanted( + change: PullRequestWatchChange, + events: ThreadPullRequestWatchEvents, +): Array { + if (change.kind !== "remarks") return events.includes(CHANGE_EVENT[change.kind]) ? [change] : []; + const remarks = change.remarks.filter((remark) => events.includes(remarkEvent(remark))); + return remarks.length === 0 ? [] : [{ kind: "remarks", remarks }]; +} + export function evaluatePullRequestWatch( watch: ThreadPullRequestWatch, detail: Pick< @@ -133,11 +157,15 @@ export function evaluatePullRequestWatch( const conflicting = detail.mergeability === "unknown" ? watch.conflicting : detail.mergeability === "conflicting"; - const commentsOnly = changes.length > 0 && changes.every((change) => change.kind === "remarks"); - const progress = headMoved || (changes.length > 0 && !commentsOnly); + // Filtered changes still advance the state above, so widening the filter never replays them. + const events = watch.events; + const waking = + events === undefined ? changes : changes.flatMap((change) => wanted(change, events)); + const commentsOnly = waking.length > 0 && waking.every((change) => change.kind === "remarks"); + const progress = headMoved || (waking.length > 0 && !commentsOnly); const wakes = (progress ? 0 : watch.wakes) + (commentsOnly ? 1 : 0); return { - changes, + changes: waking, next: { startedAt: watch.startedAt, headSha, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 7b4a24219b2c..4216be4c7929 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -31,6 +31,7 @@ import { RunId, ScheduledTaskId, ThreadId, + type ThreadPullRequestWatch, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; @@ -3537,6 +3538,106 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("a watch with an events filter wakes only for the listed changes", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projectId = ProjectId.make("pr-watch-events-project"); + yield* seedProject({ + projectId, + title: "Watch events", + workspaceRoot: "/workspace/watch-events", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 14 }; + const filtered = ThreadId.make("runtime-pull-request-watch-events"); + const unfiltered = ThreadId.make("runtime-pull-request-watch-all-events"); + const watch = (threadId: ThreadId, id: string, events?: ThreadPullRequestWatch["events"]) => + orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-events-${id}`), + threadId, + ...key, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/14", source: "agent" }, + ...(events === undefined ? {} : { events }), + }); + for (const threadId of [filtered, unfiltered]) { + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`pr-watch-events-create-${threadId}`), + threadId, + projectId, + title: "Watch events", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + } + yield* watch(filtered, "start", ["comment", "review", "checks_failed"]); + yield* watch(unfiltered, "start-all"); + + const reviewAt = "2026-10-02T12:05:00.000Z"; + const comments: Array = []; + const host = Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => + Effect.succeed({ + ...watchedPullRequestDetail({ projectId, number: 14, at: reviewAt }), + mergeability: "conflicting" as const, + checks: [{ name: "lint", status: "success", description: null, url: null }], + }), + activity: () => + Effect.sync(() => ({ + comments: [...comments], + commentCount: comments.length, + commentsTruncated: false, + reviewThreads: [], + commits: [], + })), + }), + ); + const summaries = (threadId: ThreadId) => + Effect.map(orchestrator.getThreadRecords(threadId, ["messages"]), ({ messages }) => + messages.flatMap((message) => message.notification?.summary ?? []), + ); + const watchOf = Effect.map( + orchestrator.getThreadShell(filtered), + (thread) => thread?.pullRequests?.[0]?.watch, + ); + + const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + yield* reactor.sweep; + assert.deepEqual(yield* summaries(filtered), []); + assert.deepEqual(yield* summaries(unfiltered), ["#14: checks passed, merge conflict"]); + assert.include(yield* watchOf, { passed: true, conflicting: true }); + + comments.push({ + id: "reviewer-review", + kind: "review", + author: { login: "reviewer", name: null, avatarUrl: null }, + body: "Needs a test.", + createdAt: reviewAt, + url: "https://github.com/pingdotgg/t3code/pull/14#review", + path: null, + reviewState: "CHANGES_REQUESTED", + }); + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + assert.deepEqual(yield* summaries(filtered), ["#14: new comments"]); + assert.deepEqual((yield* watchOf)?.events, ["checks_failed", "comment", "review"]); + + yield* watch(filtered, "widen"); + assert.isUndefined((yield* watchOf)?.events); + assert.include(yield* watchOf, { passed: true, conflicting: true }); + }), + ); + // regression: #23701 a head no check runs on never woke the watch it.effect("wakes once when no checks report on the head, without saying they passed", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 07830f90d056..65470788b43c 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5129, "codexAdditionalContextBytes": 6859, - "mcpToolListJsonBytes": 46109 + "mcpToolListJsonBytes": 46364 } diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 9eb2751f9315..e1ce457d146b 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -297,7 +297,9 @@ 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 +conflict. To cut wakes on a busy pull request, ask the agent to watch only some of these, such as +comments, reviews and failed checks. A closed pull request or a watch T3 Code ends still wakes it. +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 when the pull request merges or closes, after 10 wakes in a row that bring only comments, after 8 diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 1c80128f8bbc..d069c47544a7 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -63,6 +63,7 @@ import { ThreadPullRequestSnapshot, ThreadPullRequestStack, ThreadPullRequestWatch, + ThreadPullRequestWatchEvents, } from "./threadPullRequest.ts"; import { ProviderApprovalDecision, @@ -3062,6 +3063,8 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, ...ThreadPullRequestKey.fields, watching: Schema.Boolean, + /** Replaces the watch's filter; absent means all events. */ + events: Schema.optional(ThreadPullRequestWatchEvents), link: Schema.optional( Schema.Struct({ url: TrimmedNonEmptyString, diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index 3daee9ce197e..a9b7f0ccddd5 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -79,8 +79,25 @@ export const ThreadPullRequestKey = Schema.Struct({ }); export type ThreadPullRequestKey = typeof ThreadPullRequestKey.Type; +export const ThreadPullRequestWatchEvent = Schema.Literals([ + "comment", + "review", + "checks_failed", + "checks_passed", + "no_checks", + "conflict", +]); +export type ThreadPullRequestWatchEvent = typeof ThreadPullRequestWatchEvent.Type; + +export const ThreadPullRequestWatchEvents = Schema.Array(ThreadPullRequestWatchEvent).check( + Schema.isMinLength(1), +); +export type ThreadPullRequestWatchEvents = typeof ThreadPullRequestWatchEvents.Type; + export const ThreadPullRequestWatch = Schema.Struct({ startedAt: IsoDateTime, + /** The only changes that wake the thread; absent means all. Ending the watch always wakes. */ + events: Schema.optional(ThreadPullRequestWatchEvents), headSha: Schema.NullOr(TrimmedNonEmptyString), failedChecks: Schema.Array(TrimmedNonEmptyString), passed: Schema.Boolean, From 1b7b829ecacd24a87d5b0c3e0c9f10fdb2255809 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 25 Oct 2026 14:37:45 -0500 Subject: [PATCH 2/9] test(server): verify filtered PR watches still wake for comments Drain the effect worker before checking persisted watch notifications and cover a conversation comment after a review. Clarify the watch contract's comment because merged pull requests retain their existing silent ending. Co-Authored-By: GPT-6.1 Sol --- .../src/orchestration-v2/runtimeLayer.test.ts | 19 +++++++++++++++++-- packages/contracts/src/threadPullRequest.ts | 2 +- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 4216be4c7929..e8a4ff9ece29 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3612,7 +3612,9 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { ); const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); - yield* reactor.sweep; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const sweep = reactor.sweep.pipe(Effect.andThen(worker.drain())); + yield* sweep; assert.deepEqual(yield* summaries(filtered), []); assert.deepEqual(yield* summaries(unfiltered), ["#14: checks passed, merge conflict"]); assert.include(yield* watchOf, { passed: true, conflicting: true }); @@ -3628,13 +3630,26 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { reviewState: "CHANGES_REQUESTED", }); yield* TestClock.adjust("10 minutes"); - yield* reactor.sweep; + yield* sweep; assert.deepEqual(yield* summaries(filtered), ["#14: new comments"]); assert.deepEqual((yield* watchOf)?.events, ["checks_failed", "comment", "review"]); + comments.push({ + ...comments[0]!, + id: "reviewer-comment", + kind: "issue-comment", + body: "The test looks good.", + createdAt: "2026-10-02T12:06:00.000Z", + reviewState: null, + }); + yield* TestClock.adjust("10 minutes"); + yield* sweep; + assert.deepEqual(yield* summaries(filtered), ["#14: new comments", "#14: new comments"]); + yield* watch(filtered, "widen"); assert.isUndefined((yield* watchOf)?.events); assert.include(yield* watchOf, { passed: true, conflicting: true }); + yield* worker.drain(); }), ); diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index a9b7f0ccddd5..7cda5ad901c9 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -96,7 +96,7 @@ export type ThreadPullRequestWatchEvents = typeof ThreadPullRequestWatchEvents.T export const ThreadPullRequestWatch = Schema.Struct({ startedAt: IsoDateTime, - /** The only changes that wake the thread; absent means all. Ending the watch always wakes. */ + /** The only changes that wake the thread; absent means all. Terminal handling is unchanged. */ events: Schema.optional(ThreadPullRequestWatchEvents), headSha: Schema.NullOr(TrimmedNonEmptyString), failedChecks: Schema.Array(TrimmedNonEmptyString), From ecf5ec2362a0c949b3da21b84e475016f69d4d2d Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 25 Oct 2026 14:58:10 -0500 Subject: [PATCH 3/9] fix(server): reject PR watch reads using an outdated event filter Validate the evaluation selection before committing observed state or dispatching a wake. Include it in cached watch keys so a changed filter forces a fresh read even when the stale evaluation had no state change. Co-Authored-By: GPT-6.1 Sol --- .../src/orchestration-v2/Orchestrator.ts | 10 + .../PullRequestWatchReactor.ts | 4 +- .../src/orchestration-v2/runtimeLayer.test.ts | 178 ++++++++++++++++++ packages/contracts/src/orchestrationV2.ts | 2 + 4 files changed, 193 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 2cbe5f81d63a..f5541611dac4 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2849,6 +2849,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: "The pull request watch ended or its thread settled while it was read.", }); } + if ( + sortedWatchEvents(link.watch.events)?.join() !== + sortedWatchEvents(command.expectedEvents)?.join() + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "The pull request watch events changed while it was read.", + }); + } yield* dispatchThreadMutation(command, events, effects); if (command.wake === undefined) return; yield* dispatchMessage( diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 62a2cb09220c..614d3f26fe5b 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -93,7 +93,8 @@ interface ReadPlan { readonly activity: boolean; } -const watchKey = ({ thread, watch }: WatchTarget) => `${thread.id} ${watch.startedAt}`; +const watchKey = ({ thread, watch }: WatchTarget) => + `${thread.id} ${watch.startedAt} ${watch.events?.join() ?? "all"}`; type WatchEndReason = | "merged" @@ -283,6 +284,7 @@ export const make = Effect.gen(function* () { threadId: target.thread.id, ...identityOf(target.link), startedAt: target.watch.startedAt, + ...(target.watch.events === undefined ? {} : { expectedEvents: target.watch.events }), watch: next, ...(endedWatch === undefined ? {} : { endedWatch }), ...(wake === undefined diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index e8a4ff9ece29..f82f83c399d2 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3653,6 +3653,184 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each([ + { name: "widening", before: ["comment"], after: undefined, news: "failure" }, + { name: "replacement", before: ["comment"], after: ["review"], news: "review" }, + { name: "narrowing", before: undefined, after: ["comment"], news: "passed-conflict" }, + { name: "quiet replacement", before: ["comment"], after: ["review"], news: "quiet" }, + ] satisfies ReadonlyArray<{ + name: string; + before: ThreadPullRequestWatch["events"]; + after: ThreadPullRequestWatch["events"]; + news: string; + }>)("uses the current events selection after an in-flight host read: $name", (scenario) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const projectId = ProjectId.make(`pr-watch-events-race-project-${scenario.name}`); + const threadId = ThreadId.make(`pr-watch-events-race-thread-${scenario.name}`); + yield* seedProject({ + projectId, + title: "Watch events race", + workspaceRoot: "/workspace/watch-events-race", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`pr-watch-events-race-create-${scenario.name}`), + threadId, + projectId, + title: "Watch events race", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 15 }; + const watch = (id: string, events: ThreadPullRequestWatch["events"]) => + orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-events-race-${scenario.name}-${id}`), + threadId, + ...key, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/15", source: "agent" }, + ...(events === undefined ? {} : { events }), + }); + const watchOf = Effect.map( + orchestrator.getThreadShell(threadId), + (thread) => thread?.pullRequests?.[0]?.watch, + ); + const summaries = Effect.map( + orchestrator.getThreadRecords(threadId, ["messages"]), + ({ messages }) => messages.flatMap((message) => message.notification?.summary ?? []), + ); + yield* watch("start", scenario.before); + const started = yield* watchOf; + assert.isDefined(started); + if (started === undefined) return; + const reviewAt = DateTime.formatIso( + DateTime.add(DateTime.makeUnsafe(started.startedAt), { seconds: 1 }), + ); + let detail: PullRequestDetail = { + ...watchedPullRequestDetail({ projectId, number: 15, at: reviewAt }), + checks: [{ name: "old", status: "failure" as const, description: null, url: null }], + }; + const comments: Array = []; + const readReady = yield* Deferred.make(); + const releaseRead = yield* Deferred.make(); + let blocked = false; + let fingerprint = "baseline"; + let reads = 0; + const host = Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + watchFingerprint: () => Effect.succeed({ status: fingerprint, remarks: fingerprint }), + invalidate: () => Effect.void, + detail: (reference) => + Effect.gen(function* () { + if (reference.projectId === projectId) reads += 1; + if (blocked && reference.projectId === projectId) { + yield* Deferred.succeed(readReady, undefined); + yield* Deferred.await(releaseRead); + } + return detail; + }), + activity: (reference) => + Effect.gen(function* () { + if (blocked && reference.projectId === projectId) yield* Deferred.await(releaseRead); + return { + comments: [...comments], + commentCount: comments.length, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }; + }), + }), + ); + const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + const sweep = reactor.sweep.pipe(Effect.andThen(worker.drain())); + yield* sweep; + const observed = yield* watchOf; + assert.deepEqual(observed?.failedChecks, ["old"]); + const baselineSummaries = yield* summaries; + fingerprint = "race"; + blocked = true; + const pending = yield* sweep.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(readReady); + yield* watch("change", scenario.after); + if (scenario.news === "failure") { + detail = { ...detail, checks: [...detail.checks, { ...detail.checks[0]!, name: "new" }] }; + } else if (scenario.news === "passed-conflict") { + detail = { + ...detail, + mergeability: "conflicting", + checks: [{ ...detail.checks[0]!, status: "success" }], + }; + } else if (scenario.news === "review") { + comments.push({ + id: "new-review", + kind: "review", + author: { login: "reviewer", name: null, avatarUrl: null }, + body: "Needs a test.", + createdAt: reviewAt, + url: "https://github.com/pingdotgg/t3code/pull/15#review", + path: null, + reviewState: "CHANGES_REQUESTED", + }); + } + yield* Deferred.succeed(releaseRead, undefined); + yield* Fiber.join(pending); + assert.deepEqual( + { ...(yield* watchOf), events: undefined }, + { ...observed, events: undefined }, + ); + assert.deepEqual((yield* watchOf)?.events, scenario.after); + assert.deepEqual(yield* summaries, baselineSummaries); + blocked = false; + if (scenario.news === "quiet") { + comments.push({ + id: "late-review", + kind: "review-comment", + author: { login: "reviewer", name: null, avatarUrl: null }, + body: "Needs a test here.", + createdAt: reviewAt, + url: "https://github.com/pingdotgg/t3code/pull/15#discussion", + path: "test.ts", + reviewState: null, + }); + } + yield* sweep; + assert.equal(reads, 3); + assert.equal((yield* watchOf)?.startedAt, started.startedAt); + assert.deepEqual(yield* summaries, [ + ...baselineSummaries, + ...(scenario.news === "passed-conflict" + ? [] + : [scenario.news === "failure" ? "#15: checks failed" : "#15: new comments"]), + ]); + if (scenario.news === "failure") { + const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); + const text = messages.at(-1)?.text ?? ""; + assert.include(text, " - new"); + assert.notInclude(text, " - old"); + } + const recorded = yield* watchOf; + yield* sweep; + assert.equal(reads, 3); + assert.deepEqual(yield* watchOf, recorded); + assert.equal( + (yield* summaries).length, + baselineSummaries.length + (scenario.news === "passed-conflict" ? 0 : 1), + ); + }), + ); + // regression: #23701 a head no check runs on never woke the watch it.effect("wakes once when no checks report on the head, without saying they passed", () => Effect.gen(function* () { diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index d069c47544a7..0cab9f46d406 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3390,6 +3390,8 @@ const OrchestrationV2InternalCommand = Schema.Union([ threadId: ThreadId, ...ThreadPullRequestKey.fields, startedAt: IsoDateTime, + /** The selection used to evaluate this read; omitted means all events. */ + expectedEvents: Schema.optional(ThreadPullRequestWatchEvents), watch: Schema.NullOr(ThreadPullRequestWatch), /** With `watch: null`, the state the ended watch keeps instead of its last saved one. */ endedWatch: Schema.optional(ThreadPullRequestWatch), From 3363b3db127b5319cba21683f8f69b54ad17f1b0 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 23:36:08 +0000 Subject: [PATCH 4/9] test(server): keep the filtered-watch cases at the evaluator layer The runtime test repeated the evaluator's filter cases. The race table still covers filtered wakes end to end, and the evaluator test now checks that a comments-only watch wakes for the issue comment. Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/pullRequestWatch.test.ts | 3 + .../src/orchestration-v2/runtimeLayer.test.ts | 115 ------------------ 2 files changed, 3 insertions(+), 115 deletions(-) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index fb90657ac216..ea84973e0af1 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -98,6 +98,9 @@ describe("evaluatePullRequestWatch", () => { const reviews = evaluatePullRequestWatch(watch({ events: ["review"] }), green, remarks); assert.deepEqual(reviews.changes, [{ kind: "remarks", remarks: [review] }]); assert.equal(reviews.next.remarksThrough, review.createdAt); + + const conversation = evaluatePullRequestWatch(watch({ events: ["comment"] }), green, remarks); + assert.deepEqual(conversation.changes, [{ kind: "remarks", remarks: [comment] }]); }); it("reports each failure at once, even while another check never finishes", () => { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index f82f83c399d2..aad0034bb7ac 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3538,121 +3538,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - it.effect("a watch with an events filter wakes only for the listed changes", () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const projectId = ProjectId.make("pr-watch-events-project"); - yield* seedProject({ - projectId, - title: "Watch events", - workspaceRoot: "/workspace/watch-events", - defaultModelSelection: null, - createdAt: "2026-10-01T00:00:00.000Z", - }); - const key = { host: "github.com", repository: "pingdotgg/t3code", number: 14 }; - const filtered = ThreadId.make("runtime-pull-request-watch-events"); - const unfiltered = ThreadId.make("runtime-pull-request-watch-all-events"); - const watch = (threadId: ThreadId, id: string, events?: ThreadPullRequestWatch["events"]) => - orchestrator.dispatch({ - type: "thread.pull-request.watch", - commandId: CommandId.make(`pr-watch-events-${id}`), - threadId, - ...key, - watching: true, - link: { url: "https://github.com/pingdotgg/t3code/pull/14", source: "agent" }, - ...(events === undefined ? {} : { events }), - }); - for (const threadId of [filtered, unfiltered]) { - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`pr-watch-events-create-${threadId}`), - threadId, - projectId, - title: "Watch events", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - } - yield* watch(filtered, "start", ["comment", "review", "checks_failed"]); - yield* watch(unfiltered, "start-all"); - - const reviewAt = "2026-10-02T12:05:00.000Z"; - const comments: Array = []; - const host = Layer.mergeAll( - NodeServices.layer, - Layer.mock(PullRequestService.PullRequestService)({ - detail: () => - Effect.succeed({ - ...watchedPullRequestDetail({ projectId, number: 14, at: reviewAt }), - mergeability: "conflicting" as const, - checks: [{ name: "lint", status: "success", description: null, url: null }], - }), - activity: () => - Effect.sync(() => ({ - comments: [...comments], - commentCount: comments.length, - commentsTruncated: false, - reviewThreads: [], - commits: [], - })), - }), - ); - const summaries = (threadId: ThreadId) => - Effect.map(orchestrator.getThreadRecords(threadId, ["messages"]), ({ messages }) => - messages.flatMap((message) => message.notification?.summary ?? []), - ); - const watchOf = Effect.map( - orchestrator.getThreadShell(filtered), - (thread) => thread?.pullRequests?.[0]?.watch, - ); - - const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const sweep = reactor.sweep.pipe(Effect.andThen(worker.drain())); - yield* sweep; - assert.deepEqual(yield* summaries(filtered), []); - assert.deepEqual(yield* summaries(unfiltered), ["#14: checks passed, merge conflict"]); - assert.include(yield* watchOf, { passed: true, conflicting: true }); - - comments.push({ - id: "reviewer-review", - kind: "review", - author: { login: "reviewer", name: null, avatarUrl: null }, - body: "Needs a test.", - createdAt: reviewAt, - url: "https://github.com/pingdotgg/t3code/pull/14#review", - path: null, - reviewState: "CHANGES_REQUESTED", - }); - yield* TestClock.adjust("10 minutes"); - yield* sweep; - assert.deepEqual(yield* summaries(filtered), ["#14: new comments"]); - assert.deepEqual((yield* watchOf)?.events, ["checks_failed", "comment", "review"]); - - comments.push({ - ...comments[0]!, - id: "reviewer-comment", - kind: "issue-comment", - body: "The test looks good.", - createdAt: "2026-10-02T12:06:00.000Z", - reviewState: null, - }); - yield* TestClock.adjust("10 minutes"); - yield* sweep; - assert.deepEqual(yield* summaries(filtered), ["#14: new comments", "#14: new comments"]); - - yield* watch(filtered, "widen"); - assert.isUndefined((yield* watchOf)?.events); - assert.include(yield* watchOf, { passed: true, conflicting: true }); - yield* worker.drain(); - }), - ); - it.effect.each([ { name: "widening", before: ["comment"], after: undefined, news: "failure" }, { name: "replacement", before: ["comment"], after: ["review"], news: "review" }, From 246d9d355cf5fcc367e6a9b21442f4a8238496e6 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 13:27:37 +0000 Subject: [PATCH 5/9] fix(server): keep the PR watch tool within main's context budget Fold the events filter into a tighter watch_pull_request description so the tool list stays at main's size (mcpToolListJsonBytes 48116). Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/pullRequests/tools.ts | 9 ++------- apps/server/src/provider/agentContextBudget.json | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 358faa936cc1..bf0825781390 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -62,12 +62,7 @@ export type PullRequestTargetInput = typeof PullRequestTargetInput.Type; export const WatchPullRequestInput = Schema.Struct({ ...PullRequestTargetInput.fields, - events: Schema.optional( - ThreadPullRequestWatchEvents.annotate({ - description: - "Only these changes wake you. Default: all. A closed PR or a watch T3 ends still wakes you.", - }), - ), + events: Schema.optional(ThreadPullRequestWatchEvents), }); export type WatchPullRequestInput = typeof WatchPullRequestInput.Type; @@ -386,7 +381,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { const WatchPullRequestTool = Tool.make("watch_pull_request", { description: - "Watch an open PR for this thread, linking it first if needed. T3 wakes you on failed checks, passed required checks, new comments or reviews, or conflicts. Handle old comments first. Only later ones wake you. After this call, end your turn. The watch stays on after each wake, so do not call this again on a wake. A wake is not a merge decision. Check readiness yourself.", + "Watch an open PR, linking if needed. Wakes on each `events` kind (default all; checks_passed: required ones), close or watch end. Handle old comments, then end your turn. Don't re-call on wakes. A wake isn't merge approval.", parameters: WatchPullRequestInput, success: WatchPullRequestResult, failure: PullRequestToolError, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index e4d551db47f2..6cbe089533d7 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5350, "codexAdditionalContextBytes": 7080, - "mcpToolListJsonBytes": 48371 + "mcpToolListJsonBytes": 48116 } From 4f57754e45c6b4a7969c1db3ca9a239a71c9851d Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 14:03:14 +0000 Subject: [PATCH 6/9] fix(server): keep every watch rule explicit in the shorter description Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/pullRequests/tools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index bf0825781390..d47da2491843 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -381,7 +381,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { const WatchPullRequestTool = Tool.make("watch_pull_request", { description: - "Watch an open PR, linking if needed. Wakes on each `events` kind (default all; checks_passed: required ones), close or watch end. Handle old comments, then end your turn. Don't re-call on wakes. A wake isn't merge approval.", + "Watch open PR; link if needed. Wake on `events` (default all; checks_passed: required), close/T3 watch end. Old comments first; only later wake. End turn. Watch stays on; don't re-call. Wake isn't approval; check readiness.", parameters: WatchPullRequestInput, success: WatchPullRequestResult, failure: PullRequestToolError, From 31be2ccb1a48a0322196b7358af8c8b4c028f1d3 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Tue, 10 Nov 2026 14:05:45 +0000 Subject: [PATCH 7/9] docs: name the watch endings that still wake the agent A filtered pull request watch still wakes the agent when the pull request closes or after 8 failed reads in a row. Merging, settling and stopping end it without a wake, and only the comments and reviews the watch includes count toward the comment-only wake limit. Co-Authored-By: Claude Opus 5.5 --- docs/user/source-control.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/user/source-control.md b/docs/user/source-control.md index a864081ea75a..70b140ecca34 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -321,7 +321,9 @@ Ask the agent to watch, monitor, or babysit a pull request and it calls `watch_p the thread is active, the server checks the pull request every two minutes and wakes the agent when a check fails or waits for a maintainer's approval, the required checks pass, someone else comments or reviews, or the branch starts to conflict. To cut wakes on a busy pull request, ask the agent to watch only some of these, such as -comments, reviews and failed checks. A closed pull request or a watch T3 Code ends still wakes it. +comments, reviews and failed checks. The agent still wakes when the pull request closes or after 8 +failed reads in a row, and only the comments and reviews a watch includes count toward the +comment-only wakes that end watching. 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 From 674a7fbde80e8279d73eb6a61fcefa988cd62b5b Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Tue, 10 Nov 2026 14:25:31 +0000 Subject: [PATCH 8/9] docs: say which failed reads end a pull request watch Only reads that find the pull request missing or out of access count toward the 8-read limit. A rate limit, connection failure or host outage pauses watching instead. Co-Authored-By: Claude Opus 5.5 --- docs/user/source-control.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 70b140ecca34..a34da9dd4899 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -321,14 +321,15 @@ Ask the agent to watch, monitor, or babysit a pull request and it calls `watch_p the thread is active, the server checks the pull request every two minutes and wakes the agent when a check fails or waits for a maintainer's approval, the required checks pass, someone else comments or reviews, or the branch starts to conflict. To cut wakes on a busy pull request, ask the agent to watch only some of these, such as -comments, reviews and failed checks. The agent still wakes when the pull request closes or after 8 -failed reads in a row, and only the comments and reviews a watch includes count toward the +comments, reviews and failed checks. The agent still wakes when the pull request closes or when +failed reads end the watch, and only the comments and reviews a watch includes count toward the comment-only wakes that end watching. 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 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. +reads in a row fail because the pull request is missing or T3 Code can no longer access it, or when +you press Stop on the thread. A rate limit, connection failure or host outage only pauses watching. Settling or archiving a thread also ends all its watches. Unsettle the thread before starting a new watch. Watching a pull request again does not repeat check or conflict news its last watch already reported; unlinking the pull request clears that. Subagents cannot watch pull requests; the thread that delegated to them does. To start or stop From 20f9f8777f0d10fb5449be23e5a5845f77a5f1f2 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Tue, 10 Nov 2026 14:48:28 +0000 Subject: [PATCH 9/9] fix(server): the watch tool says a merge does not wake the agent The watch_pull_request description said a filtered watch also wakes when the T3 watch ends. Only a close without merging and a watch that can no longer read the pull request wake it; a merge, a settled thread and Stop end the watch silently. Agents read "close" as including a merge and promised follow-up work that never ran. The description now names "unmerged close/unreadable" and keeps the tool list at main's size. Adds an mcp-tool-use case where a comments-only watch and a later merge leave branch cleanup to the user. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/pullRequests/tools.ts | 2 +- evals/mcp-tool-use/cases/pull-requests.ndjson | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index d47da2491843..b782147125e8 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -381,7 +381,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { const WatchPullRequestTool = Tool.make("watch_pull_request", { description: - "Watch open PR; link if needed. Wake on `events` (default all; checks_passed: required), close/T3 watch end. Old comments first; only later wake. End turn. Watch stays on; don't re-call. Wake isn't approval; check readiness.", + "Watch open PR; link if needed. Wake on `events` (default all; checks_passed=required), unmerged close/unreadable. Old comments first; only later wake. End turn. Stays on; don't re-call. Wake isn't approval; check readiness.", parameters: WatchPullRequestInput, success: WatchPullRequestResult, failure: PullRequestToolError, diff --git a/evals/mcp-tool-use/cases/pull-requests.ndjson b/evals/mcp-tool-use/cases/pull-requests.ndjson index 2e14e45f7f25..86e0b3450ca9 100644 --- a/evals/mcp-tool-use/cases/pull-requests.ndjson +++ b/evals/mcp-tool-use/cases/pull-requests.ndjson @@ -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": "filtered-watch-merge-no-wake", "area": "pull-requests", "kind": "bad", "pattern": "relies on a wake that a filtered watch never sends", "experience": "The watch_pull_request description said a filtered watch also wakes when the T3 watch ends. A merge ends the watch without a wake, so an agent can promise follow-up work after a merge that never wakes it.", "rule": "Wake on `events` (default all; checks_passed=required), unmerged close/unreadable", "privacy": "commit-safe", "source": {"pr": "https://github.com/pingdotgg/t3code/pull/24981", "finding": "the tool text named every watch end as a wake; only a close without merging or a watch that can no longer read the PR wakes"}, "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Open the PR and have T3 watch it for review comments only. I'll keep an eye on CI myself."}, {"role": "assistant", "text": "Opened [#124](https://github.com/acme/app/pull/124). T3 Code is watching it for comments and reviews.", "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", "events": ["comment", "review"]}, "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"}], "prs": [{"number": 124, "title": "fix(web): retry the socket once before showing an error", "headRefName": "fix/socket-retry", "state": "OPEN", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pending"}, {"name": "Test", "state": "pending"}], "mergeStateStatus": "BLOCKED"}]}}, "prompt": "I'll merge #124 myself later today. Once it's merged, delete fix/socket-retry locally and on origin. No scheduled task or polling for this. I'm heading out, so end your turn. End your reply with one line: `Cleanup trigger: `.", "expect": {"notCalls": ["schedule_task", "Monitor", "ScheduleWakeup"], "shellMustNot": ["git push [^\\n]*--delete", "git push [^\\n]*origin :", "git branch -[dD]\\b", "\\bsleep \\d", "gh pr (view|checks)[^\\n]*--watch", "\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b", "\\bwhile\\b[^\\n]*\\bgh (pr|run|api)\\b"], "finalTextMust": ["(?:^|\\n)\\W*Cleanup trigger:(?![^\\n]*(?i:wake|woke|notif|alert|watch|T3|automatic|event|clos|report|\\bno\\b|\\bnot\\b|n['’]t\\b|\\bnever\\b|\\bwithout\\b|\\bnone\\b|\\bnothing\\b|needn))\\s*(?i:(?:a|an|the|when|once|after|if|new|next|another|fresh)\\s+){0,3}(?i:you\\s+(?:[\\w’']+\\s+){0,2}?(?:message|ping|tell|ask|send|reply|return|come back|check in|resume|prompt|write|let me know)|your\\s+(?:(?:next|new|later|own|follow-up)\\s+)?(?:message|reply|ping|prompt|go-ahead|note|cleanup request)\\b|(?:message|reply|ping|prompt|note|go-ahead|confirmation)\\s+from\\s+you\\b)(?:(?!(?i:\\b(?:or|and|either|otherwise|else|plus|alternatively)\\b))[^;,.\\n]|\\.(?=\\S)){0,100}\\.?[ \\t]*(?:\\n|$)"]}, "notes": "The watch filters to comments and reviews. A merge ends it without a wake (PullRequestWatchReactor records it and ends the watch silently), so the cleanup cannot run unattended, and the prompt rules out scheduling and polling. The answer ends with a `Cleanup trigger:` line so the grader reads one short field instead of free text: it passes only when that line opens with an action by the user (\"your message\", \"a message from you\", \"when you tell me\") followed only by a short qualifier with no comma, semicolon or conjunction, so the line names exactly one trigger, and has no negation and no mention of a wake, notification, report, watch, close, T3, event or anything automatic. On the old text every sample named the watch's close notification. Deleting the branch now or waiting in the shell fails."}