authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-05 15:22:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 15:22:14-07:00
log9e5229b47f7af66e2f88447a49f8ea7f00c734c6
treed0dc41ff9de7cf1f1cb5b30f27cb55a205a51d17
parentf33b060cafc96d7c3229744315c230a3b8d1f631
signature Signed by PGP key B5690EEEBB952194

fix(server): PR watches stop burning GitHub's rate limit and giving up (#16208)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

13 files changed, 577 insertions(+), 147 deletions(-)

apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts+35
...@@ -279,6 +279,41 @@ describe("pull request toolkit handlers", () => {...@@ -279,6 +279,41 @@ describe("pull request toolkit handlers", () => {
279 }),279 }),
280 );280 );
281281
282 it.effect("watches a pull request saved as closed, since it may have reopened", () =>
283 Effect.gen(function* () {
284 const closed = makeLink(1, { headBranch: "closed" });
285 const harness = yield* makeHarness({
286 thread: makeThread([
287 { ...closed, snapshot: closed.snapshot && { ...closed.snapshot, state: "closed" } },
288 ]),
289 });
290 yield* harness.call("watch_pull_request", { repository: "t3tools/t3code", number: 1 });
291 expect(yield* Ref.get(harness.commands)).toMatchObject([
292 { type: "thread.pull-request.watch", number: 1, watching: true },
293 ]);
294 }),
295 );
296
297 it.effect("refuses a watch from a subagent thread, whose parent owns the pull request", () =>
298 Effect.gen(function* () {
299 const harness = yield* makeHarness({
300 thread: {
301 ...makeThread([makeLink(1, { headBranch: "feature" })]),
302 lineage: {
303 rootThreadId: ThreadId.make("parent"),
304 parentThreadId: ThreadId.make("parent"),
305 relationshipToParent: "subagent",
306 },
307 },
308 });
309 const error = yield* harness
310 .call("watch_pull_request", { repository: "t3tools/t3code", number: 1 })
311 .pipe(Effect.flip);
312 expect(error).toMatchObject({ _tag: "PullRequestWatchFromSubagentError" });
313 expect(yield* Ref.get(harness.commands)).toEqual([]);
314 }),
315 );
316
282 it.effect("links by repository and number, defaulting the host to the project's", () =>317 it.effect("links by repository and number, defaulting the host to the project's", () =>
283 Effect.gen(function* () {318 Effect.gen(function* () {
284 const harness = yield* makeHarness();319 const harness = yield* makeHarness();
apps/server/src/mcp/toolkits/pullRequests/handlers.ts+8-3
...@@ -38,6 +38,7 @@ import {...@@ -38,6 +38,7 @@ import {
38 PullRequestNotOpenError,38 PullRequestNotOpenError,
39 type PullRequestTargetInput,39 type PullRequestTargetInput,
40 PullRequestWatchFailedError,40 PullRequestWatchFailedError,
41 PullRequestWatchFromSubagentError,
41 PullRequestThreadNotFoundError,42 PullRequestThreadNotFoundError,
42 PullRequestThreadAboveLimitsError,43 PullRequestThreadAboveLimitsError,
43 PullRequestThreadRequiredError,44 PullRequestThreadRequiredError,
...@@ -273,10 +274,14 @@ const make = Effect.gen(function* () {...@@ -273,10 +274,14 @@ const make = Effect.gen(function* () {
273 threadPullRequestsOf(shell).find(274 threadPullRequestsOf(shell).find(
274 (link) => link.source !== "stack-dismissed" && threadPullRequestKeysEqual(link, target),275 (link) => link.source !== "stack-dismissed" && threadPullRequestKeysEqual(link, target),
275 );276 );
277 if (watching && thread.lineage.relationshipToParent === "subagent") {
278 return yield* new PullRequestWatchFromSubagentError();
279 }
276 const before = watchedLink(thread);280 const before = watchedLink(thread);
277 const state = before?.snapshot?.state;281 // A merged pull request cannot reopen. A closed one can, and its saved state may be stale,
278 if (watching && state !== undefined && state !== "open") {282 // so the watch starts and its first read ends it if the host still says closed.
279 return yield* new PullRequestNotOpenError({ state });283 if (watching && before?.snapshot?.state === "merged") {
284 return yield* new PullRequestNotOpenError({ state: "merged" });
280 }285 }
281 yield* engine286 yield* engine
282 .dispatch({287 .dispatch({
apps/server/src/mcp/toolkits/pullRequests/tools.ts+11-1
...@@ -150,6 +150,15 @@ export class PullRequestNotOpenError extends Schema.TaggedError<PullRequestNotOp...@@ -150,6 +150,15 @@ export class PullRequestNotOpenError extends Schema.TaggedError<PullRequestNotOp
150 }150 }
151}151}
152152
153export class PullRequestWatchFromSubagentError extends Schema.TaggedError<PullRequestWatchFromSubagentError>()(
154 "PullRequestWatchFromSubagentError",
155 {},
156) {
157 override get message(): string {
158 return "This thread is a subagent, so it cannot watch pull requests. Its parent thread owns the pull request: finish your task and report back instead.";
159 }
160}
161
153export class PullRequestListFailedError extends Schema.TaggedError<PullRequestListFailedError>()(162export class PullRequestListFailedError extends Schema.TaggedError<PullRequestListFailedError>()(
154 "PullRequestListFailedError",163 "PullRequestListFailedError",
155 { cause: Schema.Defect() },164 { cause: Schema.Defect() },
...@@ -172,6 +181,7 @@ export const PullRequestToolError = Schema.Union([...@@ -172,6 +181,7 @@ export const PullRequestToolError = Schema.Union([
172 PullRequestListFailedError,181 PullRequestListFailedError,
173 PullRequestWatchFailedError,182 PullRequestWatchFailedError,
174 PullRequestNotOpenError,183 PullRequestNotOpenError,
184 PullRequestWatchFromSubagentError,
175]);185]);
176export type PullRequestToolError = typeof PullRequestToolError.Type;186export type PullRequestToolError = typeof PullRequestToolError.Type;
177187
...@@ -289,7 +299,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", {...@@ -289,7 +299,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", {
289299
290const WatchPullRequestTool = Tool.make("watch_pull_request", {300const WatchPullRequestTool = Tool.make("watch_pull_request", {
291 description:301 description:
292 "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every minute and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. While T3 Code watches, the thread stays in the user's Working list, not their inbox. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. Watching ends when the pull request merges or closes, when its thread settles, when T3 Code cannot read it for 15 minutes, when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch.",302 "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every two minutes and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. While T3 Code watches, the thread stays in the user's Working list, not their inbox. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. Watching ends when the pull request merges or closes, when its thread settles or is archived, when T3 Code fails to read it 8 times in a row (a host rate limit only delays it), when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch. A subagent cannot watch: its parent thread owns the pull request.",
293 parameters: PullRequestTargetInput,303 parameters: PullRequestTargetInput,
294 success: WatchPullRequestResult,304 success: WatchPullRequestResult,
295 failure: PullRequestToolError,305 failure: PullRequestToolError,
apps/server/src/orchestration-v2/Orchestrator.ts+25-3
...@@ -2382,12 +2382,27 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -2382,12 +2382,27 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2382 if (2382 if (
2383 command.type === "thread.pull-request.watch" &&2383 command.type === "thread.pull-request.watch" &&
2384 command.watching &&2384 command.watching &&
2385 (thread.settledOverride === "settled" || thread.settledAt !== null)2385 (thread.settledOverride === "settled" ||
2386 thread.settledAt !== null ||
2387 thread.archivedAt !== null)
2386 ) {2388 ) {
2387 return yield* new OrchestratorDispatchError({2389 return yield* new OrchestratorDispatchError({
2388 commandId: command.commandId,2390 commandId: command.commandId,
2389 commandType: command.type,2391 commandType: command.type,
2390 cause: `Thread ${command.threadId} is settled and cannot watch pull requests.`,2392 cause: `Thread ${command.threadId} is settled or archived and cannot watch pull requests.`,
2393 });
2394 }
2395 // A subagent or delegated task reports to its parent thread, which owns the pull request.
2396 // Its own watch would wake it on every change of a PR it was not asked to babysit.
2397 if (
2398 command.type === "thread.pull-request.watch" &&
2399 command.watching &&
2400 thread.lineage.relationshipToParent === "subagent"
2401 ) {
2402 return yield* new OrchestratorDispatchError({
2403 commandId: command.commandId,
2404 commandType: command.type,
2405 cause: `Thread ${command.threadId} is a subagent; its parent thread watches pull requests.`,
2391 });2406 });
2392 }2407 }
2393 // Only the agent's watch_pull_request links as "agent". A call that raced a Stop may land2408 // Only the agent's watch_pull_request links as "agent". A call that raced a Stop may land
...@@ -2713,7 +2728,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -2713,7 +2728,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2713 const updatedThread: OrchestrationV2AppThread = (() => {2728 const updatedThread: OrchestrationV2AppThread = (() => {
2714 switch (command.type) {2729 switch (command.type) {
2715 case "thread.archive":2730 case "thread.archive":
2716 return { ...thread, archivedAt: now, titleRegeneration: null, updatedAt: now };2731 // An archived thread takes no wakes, so its watches end like a settled thread's.
2732 return {
2733 ...thread,
2734 archivedAt: now,
2735 titleRegeneration: null,
2736 pullRequests: thread.pullRequests?.map((link) => withPullRequestWatch(link, undefined)),
2737 updatedAt: now,
2738 };
2717 case "thread.unarchive":2739 case "thread.unarchive":
2718 return { ...thread, archivedAt: null, updatedAt: now };2740 return { ...thread, archivedAt: null, updatedAt: now };
2719 case "thread.settle": {2741 case "thread.settle": {
apps/server/src/orchestration-v2/ProjectionStore.ts+3-1
...@@ -156,7 +156,7 @@ export type ProjectionLimitRecoveryCandidate = Pick<...@@ -156,7 +156,7 @@ export type ProjectionLimitRecoveryCandidate = Pick<
156/** The thread fields pull request sync reads, for a thread with at least one link. */156/** The thread fields pull request sync reads, for a thread with at least one link. */
157export type ProjectionThreadPullRequests = Pick<157export type ProjectionThreadPullRequests = Pick<
158 OrchestrationV2AppThread,158 OrchestrationV2AppThread,
159 "id" | "projectId" | "settledOverride" | "settledAt" | "pullRequests"159 "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" | "pullRequests"
160>;160>;
161161
162/**162/**
...@@ -5247,6 +5247,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -5247,6 +5247,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
5247 Effect.map((thread): ProjectionThreadPullRequests => ({5247 Effect.map((thread): ProjectionThreadPullRequests => ({
5248 id: thread.id,5248 id: thread.id,
5249 projectId: thread.projectId,5249 projectId: thread.projectId,
5250 lineage: thread.lineage,
5250 settledOverride: thread.settledOverride,5251 settledOverride: thread.settledOverride,
5251 settledAt: thread.settledAt,5252 settledAt: thread.settledAt,
5252 pullRequests: thread.pullRequests ?? [],5253 pullRequests: thread.pullRequests ?? [],
...@@ -5718,6 +5719,7 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(...@@ -5718,6 +5719,7 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(
5718 .map((thread): ProjectionThreadPullRequests => ({5719 .map((thread): ProjectionThreadPullRequests => ({
5719 id: thread.id,5720 id: thread.id,
5720 projectId: thread.projectId,5721 projectId: thread.projectId,
5722 lineage: thread.lineage,
5721 settledOverride: thread.settledOverride,5723 settledOverride: thread.settledOverride,
5722 settledAt: thread.settledAt,5724 settledAt: thread.settledAt,
5723 pullRequests: thread.pullRequests ?? [],5725 pullRequests: thread.pullRequests ?? [],
apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts+5
...@@ -235,6 +235,11 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options:...@@ -235,6 +235,11 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options:
235 .map((thread) => ({235 .map((thread) => ({
236 id: thread.id,236 id: thread.id,
237 projectId: thread.projectId,237 projectId: thread.projectId,
238 lineage: {
239 parentThreadId: null,
240 relationshipToParent: null,
241 rootThreadId: thread.id,
242 },
238 settledOverride: thread.settledOverride,243 settledOverride: thread.settledOverride,
239 settledAt: thread.settledAt === null ? null : DateTime.makeUnsafe(thread.settledAt),244 settledAt: thread.settledAt === null ? null : DateTime.makeUnsafe(thread.settledAt),
240 pullRequests: thread.pullRequests,245 pullRequests: thread.pullRequests,
apps/server/src/orchestration-v2/PullRequestWatchReactor.ts+208-56
...@@ -15,22 +15,48 @@ import {...@@ -15,22 +15,48 @@ import {
15 visibleThreadPullRequests,15 visibleThreadPullRequests,
16} from "@t3tools/shared/threadPullRequests";16} from "@t3tools/shared/threadPullRequests";
17import * as Cause from "effect/Cause";17import * as Cause from "effect/Cause";
18import * as Clock from "effect/Clock";
18import * as Context from "effect/Context";19import * as Context from "effect/Context";
19import * as Crypto from "effect/Crypto";20import * as Crypto from "effect/Crypto";
20import * as Effect from "effect/Effect";21import * as Effect from "effect/Effect";
21import * as Exit from "effect/Exit";22import * as Exit from "effect/Exit";
22import * as Layer from "effect/Layer";23import * as Layer from "effect/Layer";
24import * as Option from "effect/Option";
23import * as Schedule from "effect/Schedule";25import * as Schedule from "effect/Schedule";
26import * as Schema from "effect/Schema";
24import type * as Scope from "effect/Scope";27import type * as Scope from "effect/Scope";
2528
29import { PullRequestProviderError } from "../pullRequest/PullRequestProvider.ts";
26import * as PullRequestService from "../pullRequest/PullRequestService.ts";30import * as PullRequestService from "../pullRequest/PullRequestService.ts";
27import { forkParked } from "../serverActivation.ts";31import { forkParked } from "../serverActivation.ts";
28import * as Orchestrator from "./Orchestrator.ts";32import * as Orchestrator from "./Orchestrator.ts";
29import * as ProjectionStore from "./ProjectionStore.ts";33import * as ProjectionStore from "./ProjectionStore.ts";
30import { evaluatePullRequestWatch, pullRequestWatchMessage } from "./pullRequestWatch.ts";34import { evaluatePullRequestWatch, pullRequestWatchMessage } from "./pullRequestWatch.ts";
3135
32/** Passes in a row that could not read a pull request before its watch ends (one a minute). */36/**
33const READ_FAILURE_LIMIT = 15;37 * Minutes between passes. Checks take minutes, so a faster pass mostly spends the host's rate
38 * limit, which every machine on the same account shares.
39 */
40const SWEEP_MINUTES = 2;
41/** Reads in a row that failed for a reason other than a rate limit before the watch ends. */
42const READ_FAILURE_LIMIT = 8;
43/**
44 * A pull request with nothing in flight is read again only when its sync snapshot moves, or
45 * after this long, for news the snapshot cannot show, such as a bot editing its review.
46 */
47const QUIET_REREAD_MS = 10 * 60_000;
48
49const isProviderError = Schema.is(PullRequestProviderError);
50
51/** A rate limit is the host asking us to wait, not a sign the pull request cannot be read. */
52const isRateLimited = (cause: Cause.Cause<PullRequestService.PullRequestError>) =>
53 Option.match(Cause.findErrorOption(cause), {
54 onNone: () => false,
55 onSome: (error) =>
56 error._tag === "PullRequestOperationError" &&
57 isProviderError(error.cause) &&
58 error.cause.reason === "rate-limited",
59 });
3460
35const logFailure =61const logFailure =
36 (message: string, fields: Record<string, unknown>) =>62 (message: string, fields: Record<string, unknown>) =>
...@@ -45,8 +71,49 @@ interface WatchTarget {...@@ -45,8 +71,49 @@ interface WatchTarget {
45 readonly watch: ThreadPullRequestWatch;71 readonly watch: ThreadPullRequestWatch;
46}72}
4773
48const failureKey = ({ thread, link, watch }: WatchTarget) =>74/** Every thread of one project that watches one pull request, read once per pass. */
49 `${thread.id} ${threadPullRequestKeyOf(link)} ${watch.startedAt}`;75interface WatchGroup {
76 readonly key: string;
77 readonly targets: ReadonlyArray<WatchTarget>;
78 /** What sync last saw of the pull request, to skip a read when nothing moved. */
79 readonly fingerprint: string;
80}
81
82/** The last successful read of a pull request, kept in memory: a restart reads each once. */
83interface LastRead {
84 readonly at: number;
85 readonly fingerprint: string;
86 /** Nothing is in flight or unread, so only a moved snapshot or the reread brings news. */
87 readonly quiet: boolean;
88 /** The watches this read evaluated; a watch started since takes its first look next pass. */
89 readonly watches: ReadonlySet<string>;
90}
91
92const watchKey = ({ thread, watch }: WatchTarget) => `${thread.id} ${watch.startedAt}`;
93
94const snapshotFingerprint = ({ link }: WatchTarget) => {
95 const snapshot = link.snapshot;
96 return snapshot === null
97 ? ""
98 : [
99 snapshot.state,
100 snapshot.updatedAt,
101 snapshot.checksState,
102 snapshot.mergeability,
103 snapshot.reviewDecision,
104 snapshot.isDraft,
105 ].join(" ");
106};
107
108function needsRead(group: WatchGroup, last: LastRead | undefined, now: number): boolean {
109 return (
110 last === undefined ||
111 !last.quiet ||
112 last.fingerprint !== group.fingerprint ||
113 now - last.at >= QUIET_REREAD_MS ||
114 group.targets.some((target) => !last.watches.has(watchKey(target)))
115 );
116}
50117
51function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatch): boolean {118function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatch): boolean {
52 return (119 return (
...@@ -65,8 +132,11 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc...@@ -65,8 +132,11 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc
65/**132/**
66 * Wakes a thread's agent when a pull request it watches (`watch_pull_request`) needs a look:133 * Wakes a thread's agent when a pull request it watches (`watch_pull_request`) needs a look:
67 * checks finished on the head commit, someone else commented, or the branch started to134 * checks finished on the head commit, someone else commented, or the branch started to
68 * conflict. One pass a minute reads each watched pull request; settling a thread ends its135 * conflict. A pass every two minutes reads each watched pull request once for all the threads
69 * watches, and a merged or closed pull request ends its watch.136 * of a project that watch it, and skips one whose sync snapshot has not moved while nothing is
137 * in flight.
138 * Settling or archiving a thread ends its watches, and a merged or closed pull request ends
139 * its watch.
70 */140 */
71export class PullRequestWatchReactor extends Context.Service<141export class PullRequestWatchReactor extends Context.Service<
72 PullRequestWatchReactor,142 PullRequestWatchReactor,
...@@ -84,10 +154,11 @@ export const make = Effect.gen(function* () {...@@ -84,10 +154,11 @@ export const make = Effect.gen(function* () {
84 const pullRequests = yield* PullRequestService.PullRequestService;154 const pullRequests = yield* PullRequestService.PullRequestService;
85 const crypto = yield* Crypto.Crypto;155 const crypto = yield* Crypto.Crypto;
86156
87 // Passes in a row that failed, per watch. Kept in memory: a restart only delays the stop.157 // Reads in a row that failed, per pull request. Kept in memory: a restart only delays the stop.
88 const readFailures = new Map<string, number>();158 const readFailures = new Map<string, number>();
89 // Replies past each long thread's first page, per watch, so a pass pages a thread again only159 const lastReads = new Map<string, LastRead>();
90 // when the host's count of it moves. Kept in memory: a restart pages each thread once more.160 // Replies past each long thread's first page, per pull request, so a pass pages a thread
161 // again only when the host's count of it moves. A restart pages each thread once more.
91 const threadTails = new Map<162 const threadTails = new Map<
92 string,163 string,
93 Map<string, { readonly count: number; readonly comments: ReadonlyArray<PullRequestComment> }>164 Map<string, { readonly count: number; readonly comments: ReadonlyArray<PullRequestComment> }>
...@@ -128,7 +199,7 @@ export const make = Effect.gen(function* () {...@@ -128,7 +199,7 @@ export const make = Effect.gen(function* () {
128 // "Watching" while it learns nothing.199 // "Watching" while it learns nothing.
129 const giveUp = (target: WatchTarget) =>200 const giveUp = (target: WatchTarget) =>
130 record(target, null, {201 record(target, null, {
131 text: `T3 Code stopped watching pull request #${target.link.number} (${target.link.url}) because it could not read it from the host for ${READ_FAILURE_LIMIT} minutes. Check it yourself, and call watch_pull_request to watch it again.`,202 text: `T3 Code stopped watching pull request #${target.link.number} (${target.link.url}) because it failed to read it from the host ${READ_FAILURE_LIMIT} times in a row. Check it yourself, and call watch_pull_request to watch it again.`,
132 notification: {203 notification: {
133 source: { kind: "monitor" },204 source: { kind: "monitor" },
134 outcome: "failed",205 outcome: "failed",
...@@ -137,12 +208,11 @@ export const make = Effect.gen(function* () {...@@ -137,12 +208,11 @@ export const make = Effect.gen(function* () {
137 }).pipe(Effect.catch(() => record(target, null)));208 }).pipe(Effect.catch(() => record(target, null)));
138209
139 const readRemarks = Effect.fn("PullRequestWatchReactor.readRemarks")(210 const readRemarks = Effect.fn("PullRequestWatchReactor.readRemarks")(
140 function* (target: WatchTarget, reference: PullRequestRef, activity: PullRequestActivity) {211 function* (key: string, reference: PullRequestRef, activity: PullRequestActivity) {
141 // Comment cursors cannot account for missing threads. Only finish a truncated read212 // Comment cursors cannot account for missing threads. Only finish a truncated read
142 // when the host confirms that every thread was listed.213 // when the host confirms that every thread was listed.
143 if (activity.commentsTruncated && activity.reviewThreadsTruncated !== false) return null;214 if (activity.commentsTruncated && activity.reviewThreadsTruncated !== false) return null;
144215
145 const key = failureKey(target);
146 let tails = threadTails.get(key);216 let tails = threadTails.get(key);
147 if (tails === undefined) {217 if (tails === undefined) {
148 tails = new Map();218 tails = new Map();
...@@ -190,18 +260,53 @@ export const make = Effect.gen(function* () {...@@ -190,18 +260,53 @@ export const make = Effect.gen(function* () {
190 ),260 ),
191 );261 );
192262
193 const check = Effect.fn("PullRequestWatchReactor.check")(function* (target: WatchTarget) {263 // A closed pull request can reopen, but the watch has nothing to report until then.
194 const { thread, link, watch } = target;264 const closed = (target: WatchTarget) =>
195 const pullRequest = identityOf(link);265 record(target, null, {
196 // A merged pull request cannot reopen, so its watch ends without a host read. A closed one266 text: `Pull request #${target.link.number} (${target.link.url}) was closed, so T3 Code stopped watching it. Call watch_pull_request if it reopens.`,
197 // can, so the host decides below. Settling ends watches; one left from before that rule267 notification: {
198 // ends here, so the thread does not show as working while settled.268 source: { kind: "monitor" },
199 if (link.snapshot?.state === "merged") return yield* record(target, null);269 outcome: "updated",
200 if (thread.settledOverride === "settled" || thread.settledAt !== null) {270 summary: `#${target.link.number}: closed, stopped watching`,
201 return yield* record(target, null);271 },
202 }272 });
273
274 /** Watches that end without a host read; the rest are read once per pull request. */
275 const endsWithoutRead = ({ thread, link }: WatchTarget) =>
276 // A merged pull request cannot reopen. Settling and archiving end watches, and a subagent
277 // cannot start one; a watch left from before those rules ends here.
278 link.snapshot?.state === "merged" ||
279 thread.settledOverride === "settled" ||
280 thread.settledAt !== null ||
281 thread.lineage.relationshipToParent === "subagent";
203282
204 const reference = { projectId: thread.projectId, ...pullRequest };283 /**
284 * Runs one thread's step for each thread in a group, so one refusal does not skip the rest.
285 * Succeeds with whether every step landed.
286 */
287 const eachTarget = <E>(
288 group: WatchGroup,
289 step: (target: WatchTarget) => Effect.Effect<void, E>,
290 ) =>
291 Effect.forEach(group.targets, (target) =>
292 step(target).pipe(
293 Effect.as(true),
294 Effect.catchCause((cause) => {
295 // A thread that did not get its update must not wait for the quiet reread.
296 lastReads.delete(group.key);
297 return logFailure("pull request watch update failed", {
298 threadId: target.thread.id,
299 pullRequest: group.key,
300 })(cause).pipe(Effect.as(false));
301 }),
302 ),
303 ).pipe(Effect.map((landed) => landed.every(Boolean)));
304
305 const readGroup = Effect.fn("PullRequestWatchReactor.readGroup")(function* (group: WatchGroup) {
306 const now = yield* Clock.currentTimeMillis;
307 if (!needsRead(group, lastReads.get(group.key), now)) return;
308 const first = group.targets[0]!;
309 const reference = { projectId: first.thread.projectId, ...identityOf(first.link) };
205 const read = yield* Effect.exit(310 const read = yield* Effect.exit(
206 Effect.all(311 Effect.all(
207 [312 [
...@@ -211,40 +316,58 @@ export const make = Effect.gen(function* () {...@@ -211,40 +316,58 @@ export const make = Effect.gen(function* () {
211 { concurrency: 2 },316 { concurrency: 2 },
212 ),317 ),
213 );318 );
214 // Only host reads count towards giving up; a refused wake is not the host's fault.
215 const key = failureKey(target);
216 if (Exit.isFailure(read)) {319 if (Exit.isFailure(read)) {
217 if (Cause.hasInterruptsOnly(read.cause)) return yield* Effect.failCause(read.cause);320 if (Cause.hasInterruptsOnly(read.cause)) return yield* Effect.failCause(read.cause);
218 const failures = (readFailures.get(key) ?? 0) + 1;321 lastReads.delete(group.key);
219 readFailures.set(key, failures);322 // The host's pause refuses later reads without a request, so waiting it out is free.
220 // The count stays until the stop lands, so a failed stop is tried again next pass.323 if (isRateLimited(read.cause)) return;
221 if (failures >= READ_FAILURE_LIMIT) {324 const failures = (readFailures.get(group.key) ?? 0) + 1;
222 yield* giveUp(target);325 readFailures.set(group.key, failures);
223 readFailures.delete(key);326 // The count stays until every stop lands, so a failed stop is tried again on the next
327 // failed read, and a watch started after the stops begins from zero.
328 if (failures >= READ_FAILURE_LIMIT && (yield* eachTarget(group, giveUp))) {
329 readFailures.delete(group.key);
224 }330 }
225 return yield* Effect.failCause(read.cause);331 return yield* Effect.failCause(read.cause);
226 }332 }
227 readFailures.delete(key);333 readFailures.delete(group.key);
228 const [detail, activity] = read.value;334 const [detail, activity] = read.value;
229 if (detail.state !== "open") return yield* record(target, null);335 if (detail.state !== "open") {
230336 lastReads.delete(group.key);
231 // Never advance the remark watermark past comments an incomplete read could have missed.337 return yield* eachTarget(group, (target) =>
232 const remarks = yield* readRemarks(target, reference, activity);338 detail.state === "closed" ? closed(target) : record(target, null),
233 const report = evaluatePullRequestWatch(watch, detail, remarks);
234 if (report.changes.length > 0) {
235 return yield* record(
236 target,
237 report.exhausted ? null : report.next,
238 pullRequestWatchMessage({
239 number: link.number,
240 url: link.url,
241 baseBranch: detail.baseBranch,
242 headSha: report.next.headSha,
243 report,
244 }),
245 );339 );
246 }340 }
247 if (!watchesEqual(report.next, watch)) yield* record(target, report.next);341
342 // Never advance the remark watermark past comments an incomplete read could have missed.
343 const remarks = yield* readRemarks(group.key, reference, activity);
344 lastReads.set(group.key, {
345 at: now,
346 fingerprint: group.fingerprint,
347 // Comments a partial read could not see are read again next pass.
348 quiet:
349 remarks !== null &&
350 detail.mergeability !== "unknown" &&
351 detail.checks.every((check) => check.status !== "pending"),
352 watches: new Set(group.targets.map(watchKey)),
353 });
354 yield* eachTarget(group, (target) => {
355 const report = evaluatePullRequestWatch(target.watch, detail, remarks);
356 if (report.changes.length > 0) {
357 return record(
358 target,
359 report.exhausted ? null : report.next,
360 pullRequestWatchMessage({
361 number: target.link.number,
362 url: target.link.url,
363 baseBranch: detail.baseBranch,
364 headSha: report.next.headSha,
365 report,
366 }),
367 );
368 }
369 return watchesEqual(report.next, target.watch) ? Effect.void : record(target, report.next);
370 });
248 });371 });
249372
250 const sweep = Effect.gen(function* () {373 const sweep = Effect.gen(function* () {
...@@ -254,20 +377,47 @@ export const make = Effect.gen(function* () {...@@ -254,20 +377,47 @@ export const make = Effect.gen(function* () {
254 link.watch === undefined ? [] : [{ thread, link, watch: link.watch }],377 link.watch === undefined ? [] : [{ thread, link, watch: link.watch }],
255 ),378 ),
256 );379 );
257 const keys = new Set(targets.map(failureKey));380 const byPullRequest = new Map<string, Array<WatchTarget>>();
258 for (const key of readFailures.keys()) if (!keys.has(key)) readFailures.delete(key);381 const ending: Array<WatchTarget> = [];
259 for (const key of threadTails.keys()) if (!keys.has(key)) threadTails.delete(key);382 for (const target of targets) {
383 if (endsWithoutRead(target)) {
384 ending.push(target);
385 continue;
386 }
387 // Grouped per project too: each project reads through its own checkout, so one that cannot
388 // read the pull request must not end another project's watches.
389 const key = `${target.thread.projectId} ${threadPullRequestKeyOf(target.link)}`;
390 byPullRequest.set(key, [...(byPullRequest.get(key) ?? []), target]);
391 }
392 const groups = [...byPullRequest].map(([key, members]): WatchGroup => ({
393 key,
394 targets: members,
395 fingerprint: [...new Set(members.map(snapshotFingerprint))].toSorted().join("\n"),
396 }));
397 for (const cache of [readFailures, lastReads, threadTails]) {
398 for (const key of cache.keys()) if (!byPullRequest.has(key)) cache.delete(key);
399 }
260 yield* Effect.forEach(400 yield* Effect.forEach(
261 targets,401 ending,
262 (target) =>402 (target) =>
263 check(target).pipe(403 record(target, null).pipe(
264 Effect.catchCause(404 Effect.catchCause(
265 logFailure("pull request watch check failed", {405 logFailure("pull request watch stop failed", {
266 threadId: target.thread.id,406 threadId: target.thread.id,
267 pullRequest: threadPullRequestKeyOf(target.link),407 pullRequest: threadPullRequestKeyOf(target.link),
268 }),408 }),
269 ),409 ),
270 ),410 ),
411 { discard: true },
412 );
413 yield* Effect.forEach(
414 groups,
415 (group) =>
416 readGroup(group).pipe(
417 Effect.catchCause(
418 logFailure("pull request watch check failed", { pullRequest: group.key }),
419 ),
420 ),
271 { concurrency: 4, discard: true },421 { concurrency: 4, discard: true },
272 );422 );
273 }).pipe(423 }).pipe(
...@@ -276,7 +426,9 @@ export const make = Effect.gen(function* () {...@@ -276,7 +426,9 @@ export const make = Effect.gen(function* () {
276 );426 );
277427
278 const start: PullRequestWatchReactor["Service"]["start"] = () =>428 const start: PullRequestWatchReactor["Service"]["start"] = () =>
279 forkParked(sweep.pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid));429 forkParked(
430 sweep.pipe(Effect.repeat(Schedule.spaced(`${SWEEP_MINUTES} minutes`)), Effect.asVoid),
431 );
280432
281 return { start, sweep } satisfies PullRequestWatchReactor["Service"];433 return { start, sweep } satisfies PullRequestWatchReactor["Service"];
282});434});
apps/server/src/orchestration-v2/ThreadStop.test.ts+2-23
...@@ -156,7 +156,8 @@ it.effect("Stop ends watches, holds queues, and stops the delegated tasks under...@@ -156,7 +156,8 @@ it.effect("Stop ends watches, holds queues, and stops the delegated tasks under
156 yield* createWatchingThread(parentThreadId, 1);156 yield* createWatchingThread(parentThreadId, 1);
157 yield* send(parentThreadId, "first", "start_immediately");157 yield* send(parentThreadId, "first", "start_immediately");
158 const childThreadId = yield* delegate(parentThreadId, "child task");158 const childThreadId = yield* delegate(parentThreadId, "child task");
159 yield* watch(childThreadId, 2);159 // The parent owns its pull requests; a delegated task cannot watch one.
160 assert.isTrue(Exit.isFailure(yield* Effect.exit(watch(childThreadId, 2))));
160 const grandchildThreadId = yield* delegate(childThreadId, "grandchild task");161 const grandchildThreadId = yield* delegate(childThreadId, "grandchild task");
161 yield* send(childThreadId, "child follow-up", "queue_after_active");162 yield* send(childThreadId, "child follow-up", "queue_after_active");
162163
...@@ -174,9 +175,6 @@ it.effect("Stop ends watches, holds queues, and stops the delegated tasks under...@@ -174,9 +175,6 @@ it.effect("Stop ends watches, holds queues, and stops the delegated tasks under
174 });175 });
175 yield* send(parentThreadId, "second", "start_immediately");176 yield* send(parentThreadId, "second", "start_immediately");
176 const secondRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs.at(-1)!;177 const secondRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs.at(-1)!;
177 const childWatch = (yield* orchestrator.getThreadProjection(childThreadId)).thread
178 .pullRequests?.[0]?.watch;
179 assert.isDefined(childWatch);
180178
181 const stopCommandId = CommandId.make("stop-parent");179 const stopCommandId = CommandId.make("stop-parent");
182 yield* orchestrator.dispatch({180 yield* orchestrator.dispatch({
...@@ -209,25 +207,6 @@ it.effect("Stop ends watches, holds queues, and stops the delegated tasks under...@@ -209,25 +207,6 @@ it.effect("Stop ends watches, holds queues, and stops the delegated tasks under
209 });207 });
210 assert.deepEqual((yield* threadState(grandchildThreadId)).runs, ["interrupted"]);208 assert.deepEqual((yield* threadState(grandchildThreadId)).runs, ["interrupted"]);
211209
212 // A watch read that raced the Stop cannot wake the stopped child.
213 const lateWake = yield* Effect.exit(
214 orchestrator.dispatch({
215 type: "thread.pull-request-watch.sync",
216 commandId: CommandId.make("late-watch-wake"),
217 threadId: childThreadId,
218 ...pullRequest(2),
219 startedAt: childWatch!.startedAt,
220 watch: null,
221 wake: {
222 messageId: MessageId.make("message:late-watch-wake"),
223 text: "Checks passed.",
224 notification: { source: { kind: "monitor" }, outcome: "completed", summary: "#2" },
225 },
226 }),
227 );
228 assert.isTrue(Exit.isFailure(lateWake));
229 assert.deepEqual((yield* threadState(childThreadId)).runs, ["interrupted", "queued:held"]);
230
231 // A retried effect stops nothing twice.210 // A retried effect stops nothing twice.
232 yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId: stopCommandId });211 yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId: stopCommandId });
233 assert.deepEqual((yield* threadState(childThreadId)).runs, ["interrupted", "queued:held"]);212 assert.deepEqual((yield* threadState(childThreadId)).runs, ["interrupted", "queued:held"]);
apps/server/src/orchestration-v2/pullRequestWatch.test.ts+2-4
...@@ -134,7 +134,7 @@ describe("evaluatePullRequestWatch", () => {...@@ -134,7 +134,7 @@ describe("evaluatePullRequestWatch", () => {
134 ]);134 ]);
135 });135 });
136136
137 it("reports passed again when a check shows up already passed where none is required", () => {137 it("does not report passed again for a new passed check where none is required", () => {
138 const first = evaluatePullRequestWatch(138 const first = evaluatePullRequestWatch(
139 watch(),139 watch(),
140 detail({ checks: [check("test", "success")] }),140 detail({ checks: [check("test", "success")] }),
...@@ -142,9 +142,7 @@ describe("evaluatePullRequestWatch", () => {...@@ -142,9 +142,7 @@ describe("evaluatePullRequestWatch", () => {
142 );142 );
143 assert.deepEqual(first.changes, [{ kind: "checks-passed", count: 1, required: false }]);143 assert.deepEqual(first.changes, [{ kind: "checks-passed", count: 1, required: false }]);
144 const both = detail({ checks: [check("test", "success"), check("lint", "success")] });144 const both = detail({ checks: [check("test", "success"), check("lint", "success")] });
145 assert.deepEqual(evaluatePullRequestWatch(first.next, both, noRemarks).changes, [145 assert.deepEqual(evaluatePullRequestWatch(first.next, both, noRemarks).changes, []);
146 { kind: "checks-passed", count: 2, required: false },
147 ]);
148 });146 });
149147
150 it("does not wake a watch saved before passed checks were recorded", () => {148 it("does not wake a watch saved before passed checks were recorded", () => {
apps/server/src/orchestration-v2/pullRequestWatch.ts+3-2
...@@ -67,8 +67,9 @@ export function evaluatePullRequestWatch(...@@ -67,8 +67,9 @@ export function evaluatePullRequestWatch(
67 const gateNames = gate.map((check) => check.name);67 const gateNames = gate.map((check) => check.name);
68 // A watch saved before passedChecks existed takes the current names, so it does not wake.68 // A watch saved before passedChecks existed takes the current names, so it does not wake.
69 const told = passed && passedChecks.length === 0 ? gateNames : passedChecks;69 const told = passed && passedChecks.length === 0 ? gateNames : passedChecks;
70 // A required job created and finished between two passes is never seen pending.70 // A required job created and finished between two passes is never seen pending. Without
71 const gateGrew = gateNames.some((name) => !told.includes(name));71 // required checks, any check counts, and advisory bots keep adding passed ones: no wake.
72 const gateGrew = required.length > 0 && gateNames.some((name) => !told.includes(name));
72 if (passedNow && (!passed || gateGrew)) {73 if (passedNow && (!passed || gateGrew)) {
73 changes.push({ kind: "checks-passed", count: gate.length, required: required.length > 0 });74 changes.push({ kind: "checks-passed", count: gate.length, required: required.length > 0 });
74 }75 }
apps/server/src/orchestration-v2/runtimeLayer.test.ts+259-46
...@@ -63,6 +63,7 @@ import * as ProviderRuntimeRecoveryService from "./ProviderRuntimeRecoveryServic...@@ -63,6 +63,7 @@ import * as ProviderRuntimeRecoveryService from "./ProviderRuntimeRecoveryServic
63import * as ProjectionMaintenance from "./ProjectionMaintenance.ts";63import * as ProjectionMaintenance from "./ProjectionMaintenance.ts";
64import * as ProjectionStore from "./ProjectionStore.ts";64import * as ProjectionStore from "./ProjectionStore.ts";
65import * as PullRequestWatchReactor from "./PullRequestWatchReactor.ts";65import * as PullRequestWatchReactor from "./PullRequestWatchReactor.ts";
66import { PullRequestProviderError } from "../pullRequest/PullRequestProvider.ts";
66import * as PullRequestService from "../pullRequest/PullRequestService.ts";67import * as PullRequestService from "../pullRequest/PullRequestService.ts";
67import * as ProjectStore from "./ProjectStore.ts";68import * as ProjectStore from "./ProjectStore.ts";
68import type { ProviderAdapterV2SessionRuntime, ProviderAdapterV2Shape } from "./ProviderAdapter.ts";69import type { ProviderAdapterV2SessionRuntime, ProviderAdapterV2Shape } from "./ProviderAdapter.ts";
...@@ -165,6 +166,58 @@ const TestProviderInstanceRegistry = Layer.succeed(...@@ -165,6 +166,58 @@ const TestProviderInstanceRegistry = Layer.succeed(
165);166);
166167
167/** Seed a project row the way a committed `project.created` event folds into it. */168/** Seed a project row the way a committed `project.created` event folds into it. */
169/** A watched pull request as the host reports it: open, mergeable, with a failing lint check. */
170const watchedPullRequestDetail = (input: {
171 readonly projectId: ProjectId;
172 readonly number: number;
173 readonly at: string;
174}): PullRequestDetail => ({
175 provider: "github",
176 capabilities: {
177 diff: true,
178 comment: true,
179 actions: [],
180 mergeMethods: [],
181 search: false,
182 review: { inlineComment: false, reply: false, resolve: false, verdicts: [] },
183 reviewers: { request: false, listCandidates: false },
184 },
185 viewerPermissions: {
186 actions: [],
187 comment: true,
188 resolve: true,
189 verdicts: [],
190 requestReviewers: false,
191 },
192 projectId: input.projectId,
193 projectTitle: "Watch wake",
194 workspaceRoot: "/workspace/watch",
195 repository: "pingdotgg/t3code",
196 number: input.number,
197 title: "Watched pull request",
198 body: "",
199 url: `https://github.com/pingdotgg/t3code/pull/${input.number}`,
200 author: { login: "agent-user", name: null, avatarUrl: null },
201 state: "open",
202 isDraft: false,
203 mergeability: "mergeable",
204 additions: 1,
205 deletions: 0,
206 changedFiles: 1,
207 headBranch: "feature",
208 headSha: "abc1234def",
209 baseBranch: "main",
210 createdAt: input.at,
211 updatedAt: input.at,
212 mergedAt: null,
213 closedAt: null,
214 reviewers: [],
215 labels: [],
216 checks: [{ name: "lint", status: "failure", description: null, url: null }],
217 mergeCapabilities: { merge: true, squash: true, rebase: true },
218 viewer: "agent-user",
219});
220
168const seedProject = (input: {221const seedProject = (input: {
169 readonly projectId: ProjectId;222 readonly projectId: ProjectId;
170 readonly title: string;223 readonly title: string;
...@@ -2352,6 +2405,63 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => {...@@ -2352,6 +2405,63 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => {
2352 }),2405 }),
2353 );2406 );
23542407
2408 it.effect("archiving ends watches, and an archived thread cannot start one", () =>
2409 Effect.gen(function* () {
2410 const orchestrator = yield* Orchestrator.OrchestratorV2;
2411 const threadId = ThreadId.make("runtime-pull-request-watch-archive");
2412 yield* orchestrator.dispatch({
2413 type: "thread.create",
2414 createdBy: "user",
2415 creationSource: "web",
2416 commandId: CommandId.make("pr-watch-archive-create"),
2417 threadId,
2418 projectId: ProjectId.make("pr-watch-archive-project"),
2419 title: "Archive watched PR",
2420 modelSelection,
2421 runtimeMode: "full-access",
2422 interactionMode: "default",
2423 branch: null,
2424 worktreePath: null,
2425 });
2426 const watch = {
2427 type: "thread.pull-request.watch" as const,
2428 threadId,
2429 host: "github.com",
2430 repository: "pingdotgg/t3code",
2431 number: 7,
2432 watching: true,
2433 link: { url: "https://github.com/pingdotgg/t3code/pull/7", source: "agent" as const },
2434 };
2435 const watched = Effect.map(
2436 orchestrator.getThreadShell(threadId),
2437 (thread) => thread?.pullRequests?.[0]?.watch !== undefined,
2438 );
2439 yield* orchestrator.dispatch({
2440 ...watch,
2441 commandId: CommandId.make("pr-watch-archive-start"),
2442 });
2443 assert.isTrue(yield* watched);
2444
2445 yield* orchestrator.dispatch({
2446 type: "thread.archive",
2447 commandId: CommandId.make("pr-watch-archive"),
2448 threadId,
2449 });
2450 assert.isFalse(yield* watched);
2451 const refused = yield* orchestrator
2452 .dispatch({ ...watch, commandId: CommandId.make("pr-watch-archive-restart") })
2453 .pipe(Effect.flip);
2454 assert.equal(refused._tag, "OrchestratorDispatchError");
2455
2456 yield* orchestrator.dispatch({
2457 type: "thread.unarchive",
2458 commandId: CommandId.make("pr-watch-unarchive"),
2459 threadId,
2460 });
2461 assert.isFalse(yield* watched);
2462 }),
2463 );
2464
2355 it.effect("ends a watch it cannot read, and tells the agent", () =>2465 it.effect("ends a watch it cannot read, and tells the agent", () =>
2356 Effect.gen(function* () {2466 Effect.gen(function* () {
2357 const orchestrator = yield* Orchestrator.OrchestratorV2;2467 const orchestrator = yield* Orchestrator.OrchestratorV2;
...@@ -2411,6 +2521,154 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => {...@@ -2411,6 +2521,154 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => {
2411 }),2521 }),
2412 );2522 );
24132523
2524 it.effect(
2525 "reads a watched pull request once a pass, waits out rate limits, and skips quiet passes",
2526 () =>
2527 Effect.gen(function* () {
2528 const orchestrator = yield* Orchestrator.OrchestratorV2;
2529 const projectId = ProjectId.make("pr-watch-shared-project");
2530 yield* seedProject({
2531 projectId,
2532 title: "Watch shared",
2533 workspaceRoot: "/workspace/watch-shared",
2534 defaultModelSelection: null,
2535 createdAt: "2026-10-01T00:00:00.000Z",
2536 });
2537 const threadIds = ["one", "two"].map((name) =>
2538 ThreadId.make(`runtime-pull-request-watch-shared-${name}`),
2539 );
2540 const watchFrom = (threadId: ThreadId) =>
2541 Effect.gen(function* () {
2542 yield* orchestrator.dispatch({
2543 type: "thread.create",
2544 createdBy: "user",
2545 creationSource: "web",
2546 commandId: CommandId.make(`create:${threadId}`),
2547 threadId,
2548 projectId,
2549 title: "Watch shared",
2550 modelSelection,
2551 runtimeMode: "full-access",
2552 interactionMode: "default",
2553 branch: null,
2554 worktreePath: null,
2555 });
2556 yield* orchestrator.dispatch({
2557 type: "thread.pull-request.watch",
2558 commandId: CommandId.make(`watch:${threadId}`),
2559 threadId,
2560 host: "github.com",
2561 repository: "pingdotgg/t3code",
2562 number: 9,
2563 watching: true,
2564 link: { url: "https://github.com/pingdotgg/t3code/pull/9", source: "agent" },
2565 });
2566 });
2567 for (const threadId of threadIds) yield* watchFrom(threadId);
2568 const rateLimited = new PullRequestOperationError({
2569 operation: "getChangeRequest",
2570 detail: "github requests are paused until the rate limit resets",
2571 cause: new PullRequestProviderError({
2572 provider: "github",
2573 operation: "getChangeRequest",
2574 reason: "rate-limited",
2575 detail: "paused",
2576 }),
2577 });
2578 let host: "rate-limited" | "open" | "closed" = "rate-limited";
2579 let reads = 0;
2580 const reactor = yield* PullRequestWatchReactor.make.pipe(
2581 Effect.provide(
2582 Layer.mergeAll(
2583 NodeServices.layer,
2584 Layer.mock(PullRequestService.PullRequestService)({
2585 detail: () =>
2586 Effect.suspend(() => {
2587 reads += 1;
2588 return host === "rate-limited"
2589 ? Effect.fail(rateLimited)
2590 : Effect.succeed({
2591 ...watchedPullRequestDetail({
2592 projectId,
2593 number: 9,
2594 at: "2026-10-02T12:00:00.000Z",
2595 }),
2596 state: host,
2597 checks: [
2598 { name: "lint", status: "success", description: null, url: null },
2599 ],
2600 });
2601 }),
2602 activity: () =>
2603 host === "rate-limited"
2604 ? Effect.fail(rateLimited)
2605 : Effect.succeed({
2606 comments: [],
2607 commentCount: 0,
2608 commentsTruncated: false,
2609 reviewThreads: [],
2610 commits: [],
2611 }),
2612 }),
2613 ),
2614 ),
2615 );
2616 const summaries = (threadId: ThreadId) =>
2617 Effect.map(orchestrator.getThreadRecords(threadId, ["messages"]), ({ messages }) =>
2618 messages.flatMap((message) => message.notification?.summary ?? []),
2619 );
2620 const watching = (threadId: ThreadId) =>
2621 Effect.map(
2622 orchestrator.getThreadShell(threadId),
2623 (thread) => thread?.pullRequests?.[0]?.watch !== undefined,
2624 );
2625
2626 // However long the host stays rate limited, the watch waits instead of giving up, and
2627 // both threads share one read a pass.
2628 for (let pass = 0; pass < 20; pass += 1) yield* reactor.sweep;
2629 assert.equal(reads, 20);
2630 for (const threadId of threadIds) {
2631 assert.isTrue(yield* watching(threadId));
2632 assert.deepEqual(yield* summaries(threadId), []);
2633 }
2634
2635 host = "open";
2636 yield* reactor.sweep;
2637 assert.equal(reads, 21);
2638 for (const threadId of threadIds) {
2639 assert.deepEqual(yield* summaries(threadId), ["#9: checks passed"]);
2640 }
2641
2642 // Nothing in flight and nothing moved, so the next pass spends no request.
2643 yield* reactor.sweep;
2644 assert.equal(reads, 21);
2645
2646 // A new watch on the quiet pull request takes its first look on the next pass.
2647 const late = ThreadId.make("runtime-pull-request-watch-shared-late");
2648 yield* watchFrom(late);
2649 yield* reactor.sweep;
2650 assert.equal(reads, 22);
2651 assert.deepEqual(yield* summaries(late), ["#9: checks passed"]);
2652 for (const threadId of threadIds) {
2653 assert.deepEqual(yield* summaries(threadId), ["#9: checks passed"]);
2654 }
2655 threadIds.push(late);
2656
2657 // The quiet reread finds the pull request closed, ends both watches, and says so.
2658 host = "closed";
2659 yield* TestClock.adjust("10 minutes");
2660 yield* reactor.sweep;
2661 assert.equal(reads, 23);
2662 for (const threadId of threadIds) {
2663 assert.isFalse(yield* watching(threadId));
2664 assert.deepEqual(yield* summaries(threadId), [
2665 "#9: checks passed",
2666 "#9: closed, stopped watching",
2667 ]);
2668 }
2669 }),
2670 );
2671
2414 it.effect.each([2672 it.effect.each([
2415 "single page",2673 "single page",
2416 "paginated",2674 "paginated",
...@@ -2463,52 +2721,7 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => {...@@ -2463,52 +2721,7 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => {
2463 });2721 });
24642722
2465 const at = "2026-10-02T12:00:00.000Z";2723 const at = "2026-10-02T12:00:00.000Z";
2466 const detail: PullRequestDetail = {2724 const detail = watchedPullRequestDetail({ projectId, number: key.number, at });
2467 provider: "github",
2468 capabilities: {
2469 diff: true,
2470 comment: true,
2471 actions: [],
2472 mergeMethods: [],
2473 search: false,
2474 review: { inlineComment: false, reply: false, resolve: false, verdicts: [] },
2475 reviewers: { request: false, listCandidates: false },
2476 },
2477 viewerPermissions: {
2478 actions: [],
2479 comment: true,
2480 resolve: true,
2481 verdicts: [],
2482 requestReviewers: false,
2483 },
2484 projectId,
2485 projectTitle: "Watch wake",
2486 workspaceRoot: "/workspace/watch",
2487 repository: key.repository,
2488 number: key.number,
2489 title: "Watched pull request",
2490 body: "",
2491 url,
2492 author: { login: "agent-user", name: null, avatarUrl: null },
2493 state: "open",
2494 isDraft: false,
2495 mergeability: "mergeable",
2496 additions: 1,
2497 deletions: 0,
2498 changedFiles: 1,
2499 headBranch: "feature",
2500 headSha: "abc1234def",
2501 baseBranch: "main",
2502 createdAt: at,
2503 updatedAt: at,
2504 mergedAt: null,
2505 closedAt: null,
2506 reviewers: [],
2507 labels: [],
2508 checks: [{ name: "lint", status: "failure", description: null, url: null }],
2509 mergeCapabilities: { merge: true, squash: true, rebase: true },
2510 viewer: "agent-user",
2511 };
2512 const initialWatch = (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch;2725 const initialWatch = (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch;
2513 assert.isDefined(initialWatch);2726 assert.isDefined(initialWatch);
2514 const remark: PullRequestComment = {2727 const remark: PullRequestComment = {
apps/server/src/orchestration-v2/testkit/pullRequestFixtures.ts+6-1
...@@ -23,6 +23,7 @@ export interface PullRequestTestThread {...@@ -23,6 +23,7 @@ export interface PullRequestTestThread {
23 readonly linkedPullRequest?: ThreadLinkedPullRequest | null;23 readonly linkedPullRequest?: ThreadLinkedPullRequest | null;
24 readonly branchPullRequest?: ThreadLinkedPullRequest | null;24 readonly branchPullRequest?: ThreadLinkedPullRequest | null;
25 readonly latestUserMessageAt: string | null;25 readonly latestUserMessageAt: string | null;
26 readonly lineage?: OrchestrationV2ThreadShell["lineage"];
26 readonly createdAt: string;27 readonly createdAt: string;
27 readonly updatedAt: string;28 readonly updatedAt: string;
28 readonly archivedAt: string | null;29 readonly archivedAt: string | null;
...@@ -47,7 +48,11 @@ export function v2PullRequestThread(thread: PullRequestTestThread): Orchestratio...@@ -47,7 +48,11 @@ export function v2PullRequestThread(thread: PullRequestTestThread): Orchestratio
47 createdBy: "user",48 createdBy: "user",
48 creationSource: "web",49 creationSource: "web",
49 activeProviderThreadId: null,50 activeProviderThreadId: null,
50 lineage: { rootThreadId: thread.id, parentThreadId: null, relationshipToParent: null },51 lineage: thread.lineage ?? {
52 rootThreadId: thread.id,
53 parentThreadId: null,
54 relationshipToParent: null,
55 },
51 forkedFrom: null,56 forkedFrom: null,
52 latestRunId: null,57 latestRunId: null,
53 activeRunId: null,58 activeRunId: null,
docs/user/source-control.md+10-7
...@@ -192,13 +192,16 @@ when requested. With **Auto-settle merged threads** enabled, a thread can settle...@@ -192,13 +192,16 @@ when requested. With **Auto-settle merged threads** enabled, a thread can settle
192review is terminal. An open or unsynced link keeps it active.192review is terminal. An open or unsynced link keeps it active.
193193
194Ask the agent to watch, monitor, or babysit a pull request and it calls `watch_pull_request`. While194Ask the agent to watch, monitor, or babysit a pull request and it calls `watch_pull_request`. While
195the thread is active, the server checks the pull request every minute and wakes the agent when a check195the thread is active, the server checks the pull request every two minutes and wakes the agent when a
196fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict.196check fails, the required checks pass, someone else comments or reviews, or the branch starts to
197Comments from your own account do not wake it. Watching ends when the pull request merges or closes,197conflict. Threads in a project that watch the same pull request share one check, and a pull request
198after 10 wakes in a row that bring only comments, when the server cannot read the pull request for198with nothing in progress is checked again when something changes or every 10 minutes, which keeps
19915 minutes, or when you press Stop on the thread. Settling a thread also ends all its watches.199watching inside GitHub's rate limit. Comments from your own account do not wake it. Watching ends when the pull request merges or closes,
200Unsettle the thread before starting a new watch. To start or stop it yourself, use the row menu in200after 10 wakes in a row that bring only comments, after 8 failed reads in a row, or when you press
201the **Linked pull requests** panel.201Stop on the thread. A rate limit only pauses watching. Settling or archiving a thread also ends all
202its watches. Unsettle the thread before starting a new watch. Subagents cannot watch pull requests;
203the thread that delegated to them does. To start or stop it yourself, use the row menu in the
204**Linked pull requests** panel.
202205
203A watched thread counts as working between wakes, so it stays in the **Working** section and does206A watched thread counts as working between wakes, so it stays in the **Working** section and does
204not auto-settle. Agents stop watching when they hand the work back to you, and the thread then207not auto-settle. Agents stop watching when they hand the work back to you, and the thread then