authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-03 03:08:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 03:08:44-07:00
log18b21325c3ab1ff4f0ee7b017d9c4eefc2b2d307
tree9c61cc2abdf156c805caa71d78e2bc167e1235b0
parente8545b293b79cdd73d30ef921db149ef1b5abee0
signature Signed by PGP key B5690EEEBB952194

feat(server): agents can watch a PR and get woken when checks, reviews, or conflicts need them (#15057)

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

31 files changed, 1531 insertions(+), 39 deletions(-)

apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+1-1
...@@ -339,7 +339,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -339,7 +339,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
339 <SheetListRow339 <SheetListRow
340 icon="arrow.triangle.pull"340 icon="arrow.triangle.pull"
341 title={`#${link.number} ${link.snapshot?.title ?? "Pull request"}`}341 title={`#${link.number} ${link.snapshot?.title ?? "Pull request"}`}
342 subtitle={`${link.repository} · ${link.snapshot === null ? "Status pending" : link.snapshot.isDraft && link.snapshot.state === "open" ? "Draft" : link.snapshot.state}`}342 subtitle={`${link.repository} · ${link.snapshot === null ? "Status pending" : link.snapshot.isDraft && link.snapshot.state === "open" ? "Draft" : link.snapshot.state}${link.watch === undefined ? "" : " · Watching"}`}
343 onPress={() => {343 onPress={() => {
344 void tryOpenExternalUrl(link.url, "pull-request").then((opened) => {344 void tryOpenExternalUrl(link.url, "pull-request").then((opened) => {
345 if (!opened)345 if (!opened)
apps/mobile/src/features/threads/thread-work-log.tsx+2
...@@ -1368,6 +1368,8 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {...@@ -1368,6 +1368,8 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
1368 case "link-pr":1368 case "link-pr":
1369 case "unlink-pr":1369 case "unlink-pr":
1370 case "list-prs":1370 case "list-prs":
1371 case "watch-pr":
1372 case "unwatch-pr":
1371 return "arrow.triangle.pull";1373 return "arrow.triangle.pull";
1372 case "read":1374 case "read":
1373 return { ios: "eye", android: "visibility" };1375 return { ios: "eye", android: "visibility" };
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -240,6 +240,7 @@ export const make = Effect.gen(function* () {...@@ -240,6 +240,7 @@ export const make = Effect.gen(function* () {
240 threadTitleRegeneration: true,240 threadTitleRegeneration: true,
241 threadVisitedTracking: true,241 threadVisitedTracking: true,
242 threadPullRequests: true,242 threadPullRequests: true,
243 threadPullRequestWatch: true,
243 pullRequestStackActions: true,244 pullRequestStackActions: true,
244 threadPullRequestLinking: true,245 threadPullRequestLinking: true,
245 serverResolvedCommandContext: true,246 serverResolvedCommandContext: true,
apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts+53
...@@ -222,6 +222,58 @@ describe("pull request toolkit handlers", () => {...@@ -222,6 +222,58 @@ describe("pull request toolkit handlers", () => {
222 }),222 }),
223 );223 );
224224
225 it.effect("watching an unlinked pull request links it first", () =>
226 Effect.gen(function* () {
227 const harness = yield* makeHarness();
228 const result = yield* harness.call("watch_pull_request", {
229 url: "https://github.com/t3tools/t3code/pull/9",
230 });
231 // The harness thread never changes, so the result reports what it still holds.
232 expect(result).toMatchObject({ number: 9, watching: false, wasWatching: false });
233 expect(yield* Ref.get(harness.commands)).toMatchObject([
234 {
235 type: "thread.pull-request.watch",
236 number: 9,
237 watching: true,
238 link: { url: "https://github.com/t3tools/t3code/pull/9", source: "agent" },
239 },
240 ]);
241 }),
242 );
243
244 it.effect("refuses to watch a merged pull request and stops an existing watch", () =>
245 Effect.gen(function* () {
246 const watch = {
247 startedAt: "2026-08-20T00:00:00.000Z",
248 headSha: null,
249 failedChecks: [],
250 passed: false,
251 remarksThrough: "2026-08-20T00:00:00.000Z",
252 remarkIds: [],
253 conflicting: false,
254 wakes: 0,
255 };
256 const merged = makeLink(1, { headBranch: "done" });
257 const harness = yield* makeHarness({
258 thread: makeThread([
259 { ...merged, snapshot: merged.snapshot && { ...merged.snapshot, state: "merged" } },
260 makeLink(2, { headBranch: "idle" }),
261 makeLink(3, { headBranch: "watched", watch }),
262 ]),
263 });
264 const error = yield* harness
265 .call("watch_pull_request", { repository: "t3tools/t3code", number: 1 })
266 .pipe(Effect.flip);
267 expect(error).toMatchObject({ _tag: "PullRequestNotOpenError", state: "merged" });
268 expect(
269 yield* harness.call("unwatch_pull_request", { repository: "t3tools/t3code", number: 3 }),
270 ).toMatchObject({ wasWatching: true });
271 expect(yield* Ref.get(harness.commands)).toMatchObject([
272 { type: "thread.pull-request.watch", number: 3, watching: false },
273 ]);
274 }),
275 );
276
225 it.effect("links by repository and number, defaulting the host to the project's", () =>277 it.effect("links by repository and number, defaulting the host to the project's", () =>
226 Effect.gen(function* () {278 Effect.gen(function* () {
227 const harness = yield* makeHarness();279 const harness = yield* makeHarness();
...@@ -396,6 +448,7 @@ describe("pull request toolkit handlers", () => {...@@ -396,6 +448,7 @@ describe("pull request toolkit handlers", () => {
396 number: 3,448 number: 3,
397 url: "https://github.com/t3tools/t3code/pull/3",449 url: "https://github.com/t3tools/t3code/pull/3",
398 source: "agent",450 source: "agent",
451 watching: false,
399 state: "open",452 state: "open",
400 title: "PR 3",453 title: "PR 3",
401 headBranch: "feat-c",454 headBranch: "feat-c",
apps/server/src/mcp/toolkits/pullRequests/handlers.ts+64-4
...@@ -34,7 +34,9 @@ import {...@@ -34,7 +34,9 @@ import {
34 PullRequestHostRequiredError,34 PullRequestHostRequiredError,
35 PullRequestUnlinkFailedError,35 PullRequestUnlinkFailedError,
36 PullRequestListFailedError,36 PullRequestListFailedError,
37 PullRequestNotOpenError,
37 type PullRequestTargetInput,38 type PullRequestTargetInput,
39 PullRequestWatchFailedError,
38 PullRequestThreadNotFoundError,40 PullRequestThreadNotFoundError,
39 PullRequestsToolkit,41 PullRequestsToolkit,
40 type ThreadPullRequestEntry,42 type ThreadPullRequestEntry,
...@@ -122,6 +124,7 @@ function entryOf(...@@ -122,6 +124,7 @@ function entryOf(
122 number: link.number,124 number: link.number,
123 url: link.url,125 url: link.url,
124 source: link.source,126 source: link.source,
127 watching: link.watch !== undefined,
125 state: link.snapshot?.state ?? null,128 state: link.snapshot?.state ?? null,
126 title: link.snapshot?.title ?? null,129 title: link.snapshot?.title ?? null,
127 headBranch: link.snapshot?.headBranch ?? null,130 headBranch: link.snapshot?.headBranch ?? null,
...@@ -163,7 +166,8 @@ const make = Effect.gen(function* () {...@@ -163,7 +166,8 @@ const make = Effect.gen(function* () {
163 Failure:166 Failure:
164 | typeof PullRequestLinkFailedError167 | typeof PullRequestLinkFailedError
165 | typeof PullRequestUnlinkFailedError168 | typeof PullRequestUnlinkFailedError
166 | typeof PullRequestListFailedError,169 | typeof PullRequestListFailedError
170 | typeof PullRequestWatchFailedError,
167 ) {171 ) {
168 const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests");172 const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests");
169 const thread = yield* engine173 const thread = yield* engine
...@@ -178,7 +182,10 @@ const make = Effect.gen(function* () {...@@ -178,7 +182,10 @@ const make = Effect.gen(function* () {
178182
179 const projectOf = (183 const projectOf = (
180 thread: OrchestrationV2ThreadShell,184 thread: OrchestrationV2ThreadShell,
181 Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError,185 Failure:
186 | typeof PullRequestLinkFailedError
187 | typeof PullRequestUnlinkFailedError
188 | typeof PullRequestWatchFailedError,
182 ) =>189 ) =>
183 projects.getShell(thread.projectId).pipe(190 projects.getShell(thread.projectId).pipe(
184 Effect.map(Option.getOrUndefined),191 Effect.map(Option.getOrUndefined),
...@@ -186,14 +193,65 @@ const make = Effect.gen(function* () {...@@ -186,14 +193,65 @@ const make = Effect.gen(function* () {
186 );193 );
187194
188 const dispatchFailure =195 const dispatchFailure =
189 (Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError) =>196 (
197 Failure:
198 | typeof PullRequestLinkFailedError
199 | typeof PullRequestUnlinkFailedError
200 | typeof PullRequestWatchFailedError,
201 ) =>
190 <E>(202 <E>(
191 cause: Cause.Cause<E>,203 cause: Cause.Cause<E>,
192 ): Effect.Effect<never, PullRequestLinkFailedError | PullRequestUnlinkFailedError> =>204 ): Effect.Effect<
205 never,
206 PullRequestLinkFailedError | PullRequestUnlinkFailedError | PullRequestWatchFailedError
207 > =>
193 Cause.hasInterruptsOnly(cause)208 Cause.hasInterruptsOnly(cause)
194 ? Effect.failCause(cause as Cause.Cause<never>)209 ? Effect.failCause(cause as Cause.Cause<never>)
195 : Effect.fail(new Failure({ cause }));210 : Effect.fail(new Failure({ cause }));
196211
212 /**
213 * Starts or stops a watch. One command links an unlinked pull request and watches it, and the
214 * result reports the state the thread holds afterwards.
215 */
216 const setWatching = Effect.fn("PullRequestsToolkit.setWatching")(function* (
217 input: PullRequestTargetInput,
218 watching: boolean,
219 ) {
220 const thread = yield* requireThread(PullRequestWatchFailedError);
221 const project = yield* projectOf(thread, PullRequestWatchFailedError);
222 const target = yield* resolveTarget(input, project);
223 const watchedLink = (shell: OrchestrationV2ThreadShell) =>
224 threadPullRequestsOf(shell).find(
225 (link) => link.source !== "stack-dismissed" && threadPullRequestKeysEqual(link, target),
226 );
227 const before = watchedLink(thread);
228 const state = before?.snapshot?.state;
229 if (watching && state !== undefined && state !== "open") {
230 return yield* new PullRequestNotOpenError({ state });
231 }
232 yield* engine
233 .dispatch({
234 type: "thread.pull-request.watch",
235 commandId: yield* commandId("mcp-pr-watch", thread.id),
236 threadId: thread.id,
237 host: target.host,
238 repository: target.repository,
239 number: target.number,
240 watching,
241 ...(watching ? { link: { url: target.url, source: "agent" as const } } : {}),
242 })
243 .pipe(Effect.catchCause(dispatchFailure(PullRequestWatchFailedError)));
244 const after = yield* requireThread(PullRequestWatchFailedError);
245 return {
246 host: target.host,
247 repository: target.repository,
248 number: target.number,
249 url: target.url,
250 watching: watchedLink(after)?.watch !== undefined,
251 wasWatching: before?.watch !== undefined,
252 };
253 });
254
197 return PullRequestsToolkit.of({255 return PullRequestsToolkit.of({
198 link_pull_request: (input) =>256 link_pull_request: (input) =>
199 Effect.gen(function* () {257 Effect.gen(function* () {
...@@ -260,6 +318,8 @@ const make = Effect.gen(function* () {...@@ -260,6 +318,8 @@ const make = Effect.gen(function* () {
260 }),318 }),
261 list_thread_pull_requests: () =>319 list_thread_pull_requests: () =>
262 requireThread(PullRequestListFailedError).pipe(Effect.map(listThreadPullRequests)),320 requireThread(PullRequestListFailedError).pipe(Effect.map(listThreadPullRequests)),
321 watch_pull_request: (input) => setWatching(input, true),
322 unwatch_pull_request: (input) => setWatching(input, false),
263 });323 });
264});324});
265325
apps/server/src/mcp/toolkits/pullRequests/tools.ts+62
...@@ -108,6 +108,24 @@ export class PullRequestUnlinkFailedError extends Schema.TaggedError<PullRequest...@@ -108,6 +108,24 @@ export class PullRequestUnlinkFailedError extends Schema.TaggedError<PullRequest
108 }108 }
109}109}
110110
111export class PullRequestWatchFailedError extends Schema.TaggedError<PullRequestWatchFailedError>()(
112 "PullRequestWatchFailedError",
113 { cause: Schema.Defect() },
114) {
115 override get message(): string {
116 return "Could not change whether the pull request is watched.";
117 }
118}
119
120export class PullRequestNotOpenError extends Schema.TaggedError<PullRequestNotOpenError>()(
121 "PullRequestNotOpenError",
122 { state: Schema.String },
123) {
124 override get message(): string {
125 return `The pull request is ${this.state}, so there is nothing to watch.`;
126 }
127}
128
111export class PullRequestListFailedError extends Schema.TaggedError<PullRequestListFailedError>()(129export class PullRequestListFailedError extends Schema.TaggedError<PullRequestListFailedError>()(
112 "PullRequestListFailedError",130 "PullRequestListFailedError",
113 { cause: Schema.Defect() },131 { cause: Schema.Defect() },
...@@ -126,6 +144,8 @@ export const PullRequestToolError = Schema.Union([...@@ -126,6 +144,8 @@ export const PullRequestToolError = Schema.Union([
126 PullRequestLinkFailedError,144 PullRequestLinkFailedError,
127 PullRequestUnlinkFailedError,145 PullRequestUnlinkFailedError,
128 PullRequestListFailedError,146 PullRequestListFailedError,
147 PullRequestWatchFailedError,
148 PullRequestNotOpenError,
129]);149]);
130export type PullRequestToolError = typeof PullRequestToolError.Type;150export type PullRequestToolError = typeof PullRequestToolError.Type;
131151
...@@ -154,9 +174,21 @@ export const UnlinkPullRequestResult = Schema.Struct({...@@ -154,9 +174,21 @@ export const UnlinkPullRequestResult = Schema.Struct({
154});174});
155export type UnlinkPullRequestResult = typeof UnlinkPullRequestResult.Type;175export type UnlinkPullRequestResult = typeof UnlinkPullRequestResult.Type;
156176
177export const WatchPullRequestResult = Schema.Struct({
178 ...PullRequestIdentity,
179 watching: Schema.Boolean.annotate({
180 description: "Whether T3 Code now watches the pull request for this thread.",
181 }),
182 wasWatching: Schema.Boolean.annotate({
183 description: "Whether it was already watched before the call.",
184 }),
185});
186export type WatchPullRequestResult = typeof WatchPullRequestResult.Type;
187
157export const ThreadPullRequestEntry = Schema.Struct({188export const ThreadPullRequestEntry = Schema.Struct({
158 ...PullRequestIdentity,189 ...PullRequestIdentity,
159 source: ThreadPullRequestLinkSource,190 source: ThreadPullRequestLinkSource,
191 watching: Schema.Boolean,
160 state: Schema.NullOr(PullRequestState),192 state: Schema.NullOr(PullRequestState),
161 title: Schema.NullOr(Schema.String),193 title: Schema.NullOr(Schema.String),
162 headBranch: Schema.NullOr(Schema.String),194 headBranch: Schema.NullOr(Schema.String),
...@@ -224,8 +256,38 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", {...@@ -224,8 +256,38 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", {
224 .annotate(Tool.Idempotent, true)256 .annotate(Tool.Idempotent, true)
225 .annotate(Tool.OpenWorld, false);257 .annotate(Tool.OpenWorld, false);
226258
259const WatchPullRequestTool = Tool.make("watch_pull_request", {
260 description:
261 "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. Watching ends when the pull request merges or closes, when T3 Code cannot read it for 15 minutes, or when you call unwatch_pull_request.",
262 parameters: PullRequestTargetInput,
263 success: WatchPullRequestResult,
264 failure: PullRequestToolError,
265 dependencies,
266})
267 .annotate(Tool.Title, "Watch pull request")
268 .annotate(Tool.Readonly, false)
269 .annotate(Tool.Destructive, false)
270 .annotate(Tool.Idempotent, true)
271 .annotate(Tool.OpenWorld, false);
272
273const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", {
274 description:
275 "Stop T3 Code from watching a pull request for this thread. The pull request stays linked. Pass the URL, or repository plus number.",
276 parameters: PullRequestTargetInput,
277 success: WatchPullRequestResult,
278 failure: PullRequestToolError,
279 dependencies,
280})
281 .annotate(Tool.Title, "Stop watching pull request")
282 .annotate(Tool.Readonly, false)
283 .annotate(Tool.Destructive, false)
284 .annotate(Tool.Idempotent, true)
285 .annotate(Tool.OpenWorld, false);
286
227export const PullRequestsToolkit = Toolkit.make(287export const PullRequestsToolkit = Toolkit.make(
228 LinkPullRequestTool,288 LinkPullRequestTool,
229 UnlinkPullRequestTool,289 UnlinkPullRequestTool,
230 ListThreadPullRequestsTool,290 ListThreadPullRequestsTool,
291 WatchPullRequestTool,
292 UnwatchPullRequestTool,
231);293);
apps/server/src/orchestration-v2/Orchestrator.ts+172-22
...@@ -19,6 +19,8 @@ import {...@@ -19,6 +19,8 @@ import {
19 OrchestrationV2Command,19 OrchestrationV2Command,
20 type OrchestrationV2InternalCommand,20 type OrchestrationV2InternalCommand,
21 type OrchestrationV2ServerCommand,21 type OrchestrationV2ServerCommand,
22 type ThreadPullRequestLink,
23 type ThreadPullRequestWatch,
22 type OrchestrationV2AppThread,24 type OrchestrationV2AppThread,
23 type OrchestrationV2ContextHandoff,25 type OrchestrationV2ContextHandoff,
24 type OrchestrationV2ContextSourcePoint,26 type OrchestrationV2ContextSourcePoint,
...@@ -371,6 +373,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {...@@ -371,6 +373,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {
371 case "thread.pull-request.link":373 case "thread.pull-request.link":
372 case "thread.pull-request.unlink":374 case "thread.pull-request.unlink":
373 case "thread.pull-request-link.sync":375 case "thread.pull-request-link.sync":
376 case "thread.pull-request.watch":
377 case "thread.pull-request-watch.sync":
374 case "thread.pull-request.sync":378 case "thread.pull-request.sync":
375 case "thread.title.regeneration.complete":379 case "thread.title.regeneration.complete":
376 case "thread.runtime-mode.set":380 case "thread.runtime-mode.set":
...@@ -444,6 +448,35 @@ function hasLiveRun(projection: Pick<OrchestrationV2ThreadProjection, "runs">):...@@ -444,6 +448,35 @@ function hasLiveRun(projection: Pick<OrchestrationV2ThreadProjection, "runs">):
444 );448 );
445}449}
446450
451/** The link with its watch replaced, or removed when `watch` is undefined. */
452function withPullRequestWatch(
453 link: ThreadPullRequestLink,
454 watch: ThreadPullRequestWatch | undefined,
455): ThreadPullRequestLink {
456 const { watch: _previous, ...rest } = link;
457 return watch === undefined ? rest : { ...rest, watch };
458}
459
460/** A legacy single-PR link as a link entry. Re-linking a pull request keeps its watch. */
461function legacyPullRequestLink(
462 thread: OrchestrationV2AppThread,
463 linked: ThreadLinkedPullRequest,
464 now: DateTime.Utc,
465): ThreadPullRequestLink {
466 const key = legacyThreadPullRequestKey(linked);
467 return withPullRequestWatch(
468 {
469 ...key,
470 url: linked.url,
471 source: "manual",
472 linkedAt: DateTime.formatIso(now),
473 snapshot: null,
474 stack: null,
475 },
476 threadPullRequestsOf(thread).find((link) => threadPullRequestKeysEqual(link, key))?.watch,
477 );
478}
479
447function delegatedCompletionWakeDetail(taskIds: ReadonlyArray<string>): string {480function delegatedCompletionWakeDetail(taskIds: ReadonlyArray<string>): string {
448 const taskList = taskIds.join(", ");481 const taskList = taskIds.join(", ");
449 return taskIds.length === 1482 return taskIds.length === 1
...@@ -2188,9 +2221,66 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -2188,9 +2221,66 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2188 });2221 });
2189 });2222 });
21902223
2224 // Checked under the thread lock: the watch or the thread can change while the host is read.
2225 // The watch is recorded first so the wake's own thread events carry it.
2226 const dispatchPullRequestWatchSync = Effect.fn("orchestrationV2.dispatch.pullRequestWatchSync")(
2227 function* (
2228 command: Extract<
2229 OrchestrationV2ServerCommand,
2230 { readonly type: "thread.pull-request-watch.sync" }
2231 >,
2232 events: Ref.Ref<Array<OrchestrationV2DomainEvent>>,
2233 effects: Ref.Ref<Array<PendingOrchestrationEffectV2>>,
2234 ) {
2235 const thread = yield* projectionStore
2236 .getThread(command.threadId)
2237 .pipe(
2238 Effect.mapError(
2239 (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }),
2240 ),
2241 );
2242 const key = normalizeThreadPullRequestKey(command);
2243 const link = threadPullRequestsOf(thread).find(
2244 (candidate) =>
2245 candidate.source !== "stack-dismissed" && threadPullRequestKeysEqual(candidate, key),
2246 );
2247 // Same rule as a direct message.dispatch: a provider-native subagent takes no messages.
2248 const inactive =
2249 thread.archivedAt !== null ||
2250 thread.settledOverride === "settled" ||
2251 thread.settledAt !== null ||
2252 isProviderNativeSubagentThread(thread);
2253 if (link?.watch?.startedAt !== command.startedAt || (command.wake && inactive)) {
2254 return yield* new OrchestratorDispatchError({
2255 commandId: command.commandId,
2256 commandType: command.type,
2257 cause: "The pull request watch ended or its thread settled while it was read.",
2258 });
2259 }
2260 yield* dispatchThreadMutation(command, events, effects);
2261 if (command.wake === undefined) return;
2262 yield* dispatchMessage(
2263 {
2264 type: "message.dispatch",
2265 commandId: command.commandId,
2266 threadId: command.threadId,
2267 messageId: command.wake.messageId,
2268 text: command.wake.text,
2269 notification: command.wake.notification,
2270 attachments: [],
2271 dispatchMode: { type: "queue_after_active" },
2272 createdBy: "agent",
2273 creationSource: "server",
2274 },
2275 events,
2276 effects,
2277 );
2278 },
2279 );
2280
2191 const dispatchThreadMutation = Effect.fn("orchestrationV2.dispatch.threadMutation")(function* (2281 const dispatchThreadMutation = Effect.fn("orchestrationV2.dispatch.threadMutation")(function* (
2192 command: Extract<2282 command: Extract<
2193 OrchestrationV2Command,2283 OrchestrationV2ServerCommand,
2194 {2284 {
2195 readonly type:2285 readonly type:
2196 | "thread.archive"2286 | "thread.archive"
...@@ -2209,6 +2299,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -2209,6 +2299,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2209 | "thread.pull-request.link"2299 | "thread.pull-request.link"
2210 | "thread.pull-request.unlink"2300 | "thread.pull-request.unlink"
2211 | "thread.pull-request-link.sync"2301 | "thread.pull-request-link.sync"
2302 | "thread.pull-request.watch"
2303 | "thread.pull-request-watch.sync"
2212 | "thread.pull-request.sync"2304 | "thread.pull-request.sync"
2213 | "thread.title.regeneration.complete"2305 | "thread.title.regeneration.complete"
2214 | "thread.runtime-mode.set"2306 | "thread.runtime-mode.set"
...@@ -2236,6 +2328,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -2236,6 +2328,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2236 cause: `Thread ${command.threadId} is deleted.`,2328 cause: `Thread ${command.threadId} is deleted.`,
2237 });2329 });
2238 }2330 }
2331 if (
2332 command.type === "thread.pull-request.watch" &&
2333 command.watching &&
2334 isProviderNativeSubagentThread(thread)
2335 ) {
2336 return yield* new OrchestratorSubagentThreadReadOnlyError({
2337 commandId: command.commandId,
2338 threadId: command.threadId,
2339 });
2340 }
2239 if (2341 if (
2240 command.type === "thread.metadata.update" &&2342 command.type === "thread.metadata.update" &&
2241 command.expectedWorktreePath !== undefined &&2343 command.expectedWorktreePath !== undefined &&
...@@ -2719,16 +2821,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -2719,16 +2821,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2719 ),2821 ),
2720 ),2822 ),
2721 ...(command.linkedPullRequest2823 ...(command.linkedPullRequest
2722 ? [2824 ? [legacyPullRequestLink(thread, command.linkedPullRequest, now)]
2723 {
2724 ...legacyThreadPullRequestKey(command.linkedPullRequest),
2725 url: command.linkedPullRequest.url,
2726 source: "manual" as const,
2727 linkedAt: DateTime.formatIso(now),
2728 snapshot: null,
2729 stack: null,
2730 },
2731 ]
2732 : []),2825 : []),
2733 ],2826 ],
2734 }),2827 }),
...@@ -2801,7 +2894,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -2801,7 +2894,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2801 );2894 );
2802 pullRequests = belongsToStack2895 pullRequests = belongsToStack
2803 ? links.map((link) =>2896 ? links.map((link) =>
2804 link === existing ? { ...link, source: "stack-dismissed" as const } : link,2897 link === existing
2898 ? {
2899 ...withPullRequestWatch(link, undefined),
2900 source: "stack-dismissed" as const,
2901 }
2902 : link,
2805 )2903 )
2806 : links.filter((link) => link !== existing);2904 : links.filter((link) => link !== existing);
2807 } else {2905 } else {
...@@ -2830,6 +2928,61 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -2830,6 +2928,61 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2830 updatedAt: command.type === "thread.pull-request-link.sync" ? thread.updatedAt : now,2928 updatedAt: command.type === "thread.pull-request-link.sync" ? thread.updatedAt : now,
2831 };2929 };
2832 }2930 }
2931 case "thread.pull-request.watch":
2932 case "thread.pull-request-watch.sync": {
2933 const key = normalizeThreadPullRequestKey(command);
2934 const startedAt = DateTime.formatIso(now);
2935 const linked = threadPullRequestsOf(thread);
2936 const visible = (link: ThreadPullRequestLink) =>
2937 link.source !== "stack-dismissed" && threadPullRequestKeysEqual(link, key);
2938 // A watch started on an unlinked (or dismissed) pull request links it in the same step.
2939 const links =
2940 command.type === "thread.pull-request.watch" &&
2941 command.watching &&
2942 command.link !== undefined &&
2943 !linked.some(visible)
2944 ? [
2945 ...linked.filter((link) => !threadPullRequestKeysEqual(link, key)),
2946 {
2947 ...key,
2948 url: command.link.url,
2949 source: command.link.source,
2950 linkedAt: startedAt,
2951 snapshot: null,
2952 stack: null,
2953 },
2954 ]
2955 : linked;
2956 const existing = links.find(visible);
2957 if (existing === undefined) return thread;
2958 const watch =
2959 command.type === "thread.pull-request-watch.sync"
2960 ? // Progress read before a stop or restart must not bring the old watch back.
2961 existing.watch?.startedAt === command.startedAt
2962 ? (command.watch ?? undefined)
2963 : existing.watch
2964 : !command.watching
2965 ? undefined
2966 : (existing.watch ?? {
2967 startedAt,
2968 headSha: null,
2969 failedChecks: [],
2970 passed: false,
2971 remarksThrough: startedAt,
2972 remarkIds: [],
2973 conflicting: false,
2974 wakes: 0,
2975 });
2976 if (watch === existing.watch && links === linked) return thread;
2977 return {
2978 ...thread,
2979 pullRequests: links.map((link) =>
2980 link === existing ? withPullRequestWatch(link, watch) : link,
2981 ),
2982 // A user or agent starting or stopping a watch is activity; recorded progress is not.
2983 updatedAt: command.type === "thread.pull-request.watch" ? now : thread.updatedAt,
2984 };
2985 }
2833 case "thread.pull-request.sync":2986 case "thread.pull-request.sync":
2834 return {2987 return {
2835 ...thread,2988 ...thread,
...@@ -2857,16 +3010,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -2857,16 +3010,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2857 ),3010 ),
2858 ),3011 ),
2859 ...(command.linkedPullRequest3012 ...(command.linkedPullRequest
2860 ? [3013 ? [legacyPullRequestLink(thread, command.linkedPullRequest, now)]
2861 {
2862 ...legacyThreadPullRequestKey(command.linkedPullRequest),
2863 url: command.linkedPullRequest.url,
2864 source: "manual" as const,
2865 linkedAt: DateTime.formatIso(now),
2866 snapshot: null,
2867 stack: null,
2868 },
2869 ]
2870 : []),3014 : []),
2871 ],3015 ],
2872 }),3016 }),
...@@ -2927,6 +3071,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -2927,6 +3071,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2927 case "thread.pull-request.link":3071 case "thread.pull-request.link":
2928 case "thread.pull-request.unlink":3072 case "thread.pull-request.unlink":
2929 case "thread.pull-request-link.sync":3073 case "thread.pull-request-link.sync":
3074 case "thread.pull-request.watch":
3075 case "thread.pull-request-watch.sync":
2930 case "thread.pull-request.sync":3076 case "thread.pull-request.sync":
2931 return "thread.pull-request-synced" as const;3077 return "thread.pull-request-synced" as const;
2932 case "thread.runtime-mode.set":3078 case "thread.runtime-mode.set":
...@@ -9130,6 +9276,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -9130,6 +9276,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
9130 case "thread.pull-request.link":9276 case "thread.pull-request.link":
9131 case "thread.pull-request.unlink":9277 case "thread.pull-request.unlink":
9132 case "thread.pull-request-link.sync":9278 case "thread.pull-request-link.sync":
9279 case "thread.pull-request.watch":
9133 case "thread.pull-request.sync":9280 case "thread.pull-request.sync":
9134 case "thread.title.regeneration.complete":9281 case "thread.title.regeneration.complete":
9135 case "thread.runtime-mode.set":9282 case "thread.runtime-mode.set":
...@@ -9138,6 +9285,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -9138,6 +9285,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
9138 case "provider.switch":9285 case "provider.switch":
9139 yield* dispatchThreadMutation(command, events, effects);9286 yield* dispatchThreadMutation(command, events, effects);
9140 break;9287 break;
9288 case "thread.pull-request-watch.sync":
9289 yield* dispatchPullRequestWatchSync(command, events, effects);
9290 break;
9141 case "provider-session.detach":9291 case "provider-session.detach":
9142 yield* dispatchProviderSessionDetach(command, events, effects);9292 yield* dispatchProviderSessionDetach(command, events, effects);
9143 break;9293 break;
apps/server/src/orchestration-v2/PullRequestWatchReactor.ts created+219
...@@ -0,0 +1,219 @@
1import {
2 CommandId,
3 MessageId,
4 type OrchestrationV2Notification,
5 type ThreadPullRequestLink,
6 type ThreadPullRequestWatch,
7} from "@t3tools/contracts";
8import {
9 normalizeThreadPullRequestKey,
10 threadPullRequestKeyOf,
11 visibleThreadPullRequests,
12} from "@t3tools/shared/threadPullRequests";
13import * as Cause from "effect/Cause";
14import * as Context from "effect/Context";
15import * as Crypto from "effect/Crypto";
16import * as Effect from "effect/Effect";
17import * as Exit from "effect/Exit";
18import * as Layer from "effect/Layer";
19import * as Schedule from "effect/Schedule";
20import type * as Scope from "effect/Scope";
21
22import * as PullRequestService from "../pullRequest/PullRequestService.ts";
23import { forkParked } from "../serverActivation.ts";
24import * as Orchestrator from "./Orchestrator.ts";
25import * as ProjectionStore from "./ProjectionStore.ts";
26import { evaluatePullRequestWatch, pullRequestWatchMessage } from "./pullRequestWatch.ts";
27
28/** Passes in a row that could not read a pull request before its watch ends (one a minute). */
29const READ_FAILURE_LIMIT = 15;
30
31const logFailure =
32 (message: string, fields: Record<string, unknown>) =>
33 <E>(cause: Cause.Cause<E>): Effect.Effect<void> =>
34 Cause.hasInterruptsOnly(cause)
35 ? Effect.interrupt
36 : Effect.logWarning(message, { ...fields, cause });
37
38interface WatchTarget {
39 readonly thread: ProjectionStore.ProjectionThreadPullRequests;
40 readonly link: ThreadPullRequestLink;
41 readonly watch: ThreadPullRequestWatch;
42}
43
44const failureKey = ({ thread, link, watch }: WatchTarget) =>
45 `${thread.id} ${threadPullRequestKeyOf(link)} ${watch.startedAt}`;
46
47function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatch): boolean {
48 return (
49 left.startedAt === right.startedAt &&
50 left.headSha === right.headSha &&
51 left.failedChecks.join("\n") === right.failedChecks.join("\n") &&
52 left.passed === right.passed &&
53 left.remarksThrough === right.remarksThrough &&
54 left.remarkIds.join("\n") === right.remarkIds.join("\n") &&
55 left.conflicting === right.conflicting &&
56 left.wakes === right.wakes
57 );
58}
59
60/**
61 * Wakes a thread's agent when a pull request it watches (`watch_pull_request`) needs a look:
62 * checks finished on the head commit, someone else commented, or the branch started to
63 * conflict. One pass a minute reads each watched pull request; settled threads wait until
64 * they are active again, and a merged or closed pull request ends its watch.
65 */
66export class PullRequestWatchReactor extends Context.Service<
67 PullRequestWatchReactor,
68 {
69 readonly start: () => Effect.Effect<void, never, Scope.Scope>;
70 /** One pass over every watched pull request. */
71 readonly sweep: Effect.Effect<void>;
72 }
73>()("t3/orchestration-v2/PullRequestWatchReactor") {}
74
75/** @public Service construction is part of the canonical Effect module API. */
76export const make = Effect.gen(function* () {
77 const engine = yield* Orchestrator.OrchestratorV2;
78 const projections = yield* ProjectionStore.ProjectionStoreV2;
79 const pullRequests = yield* PullRequestService.PullRequestService;
80 const crypto = yield* Crypto.Crypto;
81
82 // Passes in a row that failed, per watch. Kept in memory: a restart only delays the stop.
83 const readFailures = new Map<string, number>();
84
85 // Host-level identity, with the repository as linked, the way pull request sync reads it.
86 const identityOf = (link: ThreadPullRequestLink) => ({
87 host: normalizeThreadPullRequestKey(link).host,
88 repository: link.repository,
89 number: link.number,
90 });
91
92 /**
93 * Records what a pass saw, and wakes the agent with it. The orchestrator applies this only
94 * while the same watch is on, so a stop or restart that lands during the host read wins.
95 */
96 const record = (
97 target: WatchTarget,
98 next: ThreadPullRequestWatch | null,
99 wake?: { readonly text: string; readonly notification: OrchestrationV2Notification },
100 ) =>
101 Effect.gen(function* () {
102 const uuid = yield* crypto.randomUUIDv4;
103 yield* engine.dispatch({
104 type: "thread.pull-request-watch.sync",
105 commandId: CommandId.make(`server:pr-watch:${target.thread.id}:${uuid}`),
106 threadId: target.thread.id,
107 ...identityOf(target.link),
108 startedAt: target.watch.startedAt,
109 watch: next,
110 ...(wake === undefined
111 ? {}
112 : { wake: { ...wake, messageId: MessageId.make(`message:pr-watch:${uuid}`) } }),
113 });
114 });
115
116 // A watch that cannot read its pull request ends with a wake saying so, rather than showing
117 // "Watching" while it learns nothing.
118 const giveUp = (target: WatchTarget) =>
119 record(target, null, {
120 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.`,
121 notification: {
122 source: { kind: "monitor" },
123 outcome: "failed",
124 summary: `#${target.link.number}: stopped watching, could not read it`,
125 },
126 }).pipe(Effect.catch(() => record(target, null)));
127
128 const check = Effect.fn("PullRequestWatchReactor.check")(function* (target: WatchTarget) {
129 const { thread, link, watch } = target;
130 const pullRequest = identityOf(link);
131 // A merged pull request cannot reopen, so its watch ends without a host read, even on a
132 // settled thread. A closed one can, so the host decides below.
133 if (link.snapshot?.state === "merged") return yield* record(target, null);
134 if (thread.settledOverride === "settled" || thread.settledAt !== null) return;
135
136 const reference = { projectId: thread.projectId, ...pullRequest };
137 const read = yield* Effect.exit(
138 Effect.all(
139 [
140 pullRequests.detail({ ...reference, allowStale: false }),
141 pullRequests.activity(reference),
142 ],
143 { concurrency: 2 },
144 ),
145 );
146 // Only host reads count towards giving up; a refused wake is not the host's fault.
147 const key = failureKey(target);
148 if (Exit.isFailure(read)) {
149 if (Cause.hasInterruptsOnly(read.cause)) return yield* Effect.failCause(read.cause);
150 const failures = (readFailures.get(key) ?? 0) + 1;
151 readFailures.set(key, failures);
152 // The count stays until the stop lands, so a failed stop is tried again next pass.
153 if (failures >= READ_FAILURE_LIMIT) {
154 yield* giveUp(target);
155 readFailures.delete(key);
156 }
157 return yield* Effect.failCause(read.cause);
158 }
159 readFailures.delete(key);
160 const [detail, activity] = read.value;
161 if (detail.state !== "open") return yield* record(target, null);
162
163 // A degraded read (GitHub's review thread query failed) is truncated with no long thread to
164 // explain it, and would skip review comments, so remarks wait for a later pass. Replies past
165 // the first ten of a long review thread are not read.
166 const degraded =
167 activity.commentsTruncated &&
168 !activity.reviewThreads.some((reviewThread) => reviewThread.nextCommentsCursor !== undefined);
169 const report = evaluatePullRequestWatch(watch, detail, degraded ? null : activity.comments);
170 if (report.changes.length > 0) {
171 return yield* record(
172 target,
173 report.exhausted ? null : report.next,
174 pullRequestWatchMessage({
175 number: link.number,
176 url: link.url,
177 baseBranch: detail.baseBranch,
178 headSha: report.next.headSha,
179 report,
180 }),
181 );
182 }
183 if (!watchesEqual(report.next, watch)) yield* record(target, report.next);
184 });
185
186 const sweep = Effect.gen(function* () {
187 const threads = yield* projections.getThreadsWithPullRequests();
188 const targets = threads.flatMap((thread) =>
189 visibleThreadPullRequests(thread.pullRequests ?? []).flatMap((link) =>
190 link.watch === undefined ? [] : [{ thread, link, watch: link.watch }],
191 ),
192 );
193 const keys = new Set(targets.map(failureKey));
194 for (const key of readFailures.keys()) if (!keys.has(key)) readFailures.delete(key);
195 yield* Effect.forEach(
196 targets,
197 (target) =>
198 check(target).pipe(
199 Effect.catchCause(
200 logFailure("pull request watch check failed", {
201 threadId: target.thread.id,
202 pullRequest: threadPullRequestKeyOf(target.link),
203 }),
204 ),
205 ),
206 { concurrency: 4, discard: true },
207 );
208 }).pipe(
209 Effect.catchCause(logFailure("pull request watch sweep failed", {})),
210 Effect.withSpan("PullRequestWatchReactor.sweep"),
211 );
212
213 const start: PullRequestWatchReactor["Service"]["start"] = () =>
214 forkParked(sweep.pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid));
215
216 return { start, sweep } satisfies PullRequestWatchReactor["Service"];
217});
218
219export const layer = Layer.effect(PullRequestWatchReactor, make);
apps/server/src/orchestration-v2/pullRequestWatch.test.ts created+217
...@@ -0,0 +1,217 @@
1import type {
2 PullRequestCheck,
3 PullRequestComment,
4 PullRequestDetail,
5 ThreadPullRequestWatch,
6} from "@t3tools/contracts";
7import { assert, describe, it } from "@effect/vitest";
8
9import {
10 PULL_REQUEST_WATCH_WAKE_LIMIT,
11 evaluatePullRequestWatch,
12 pullRequestWatchMessage,
13} from "./pullRequestWatch.ts";
14
15const STARTED = "2026-10-02T12:00:00.000Z";
16
17const watch = (overrides: Partial<ThreadPullRequestWatch> = {}): ThreadPullRequestWatch => ({
18 startedAt: STARTED,
19 headSha: null,
20 failedChecks: [],
21 passed: false,
22 remarksThrough: STARTED,
23 remarkIds: [],
24 conflicting: false,
25 wakes: 0,
26 ...overrides,
27});
28
29const check = (name: string, status: PullRequestCheck["status"]): PullRequestCheck => ({
30 name,
31 status,
32 description: null,
33 url: `https://ci.example/${name}`,
34});
35
36type Detail = Parameters<typeof evaluatePullRequestWatch>[1];
37
38const detail = (overrides: Partial<Detail> = {}): Detail => ({
39 headSha: "aaaaaaaaaa",
40 checks: [check("lint", "success"), check("test", "pending")],
41 mergeability: "mergeable",
42 viewer: "agent-user",
43 author: { login: "agent-user", name: null, avatarUrl: null },
44 ...overrides,
45});
46
47const remark = (
48 login: string,
49 createdAt: string,
50 body = "Please rename this.",
51): PullRequestComment => ({
52 id: `${login}-${createdAt}`,
53 kind: "review-comment",
54 author: { login, name: null, avatarUrl: null },
55 body,
56 createdAt,
57 url: `https://github.com/o/r/pull/1#${login}`,
58 path: "src/index.ts",
59 reviewState: null,
60});
61
62const noRemarks: ReadonlyArray<PullRequestComment> = [];
63
64describe("evaluatePullRequestWatch", () => {
65 it("reports each failure at once, even while another check never finishes", () => {
66 const bot = check("CodeRabbit", "pending");
67 const first = detail({ checks: [check("lint", "failure"), check("test", "pending"), bot] });
68 const lint = evaluatePullRequestWatch(watch(), first, noRemarks);
69 assert.deepEqual(lint.changes, [{ kind: "checks-failed", failed: [check("lint", "failure")] }]);
70 assert.deepEqual(evaluatePullRequestWatch(lint.next, first, noRemarks).changes, []);
71
72 // A different job failing later is news of its own.
73 const second = detail({ checks: [check("lint", "failure"), check("test", "failure"), bot] });
74 const test = evaluatePullRequestWatch(lint.next, second, noRemarks);
75 assert.deepEqual(test.changes, [{ kind: "checks-failed", failed: [check("test", "failure")] }]);
76
77 // A rerun leaves the list while it runs, so failing again is reported again.
78 const rerun = evaluatePullRequestWatch(test.next, first, noRemarks);
79 assert.equal(evaluatePullRequestWatch(rerun.next, second, noRemarks).changes.length, 1);
80
81 // A push reports its failures, even ones that failed between two passes.
82 const pushed = detail({ ...second, headSha: "bbbbbbbbbb" });
83 assert.equal(evaluatePullRequestWatch(test.next, pushed, noRemarks).changes.length, 1);
84 });
85
86 it("reports passed once the required checks pass, whatever the others do", () => {
87 const required = (name: string, status: PullRequestCheck["status"]) => ({
88 ...check(name, status),
89 required: true,
90 });
91 const green = detail({
92 checks: [required("test", "success"), required("lint", "success"), check("bot", "pending")],
93 });
94 const passed = evaluatePullRequestWatch(watch(), green, noRemarks);
95 assert.deepEqual(passed.changes, [{ kind: "checks-passed", count: 2, required: true }]);
96 assert.deepEqual(evaluatePullRequestWatch(passed.next, green, noRemarks).changes, []);
97
98 // Where nothing is marked required, every check has to pass.
99 const plain = detail({ checks: [check("test", "success"), check("bot", "pending")] });
100 assert.deepEqual(evaluatePullRequestWatch(watch(), plain, noRemarks).changes, []);
101 });
102
103 it("keeps remarks for a later pass when the conversation was not read whole", () => {
104 const comments = [remark("reviewer", "2026-10-02T12:06:00Z")];
105 const partial = evaluatePullRequestWatch(watch(), detail(), null);
106 assert.deepEqual(partial.changes, []);
107 assert.equal(
108 evaluatePullRequestWatch(partial.next, detail(), comments).changes[0]?.kind,
109 "remarks",
110 );
111 });
112
113 it("reports a remark that shows up late with the same time as a reported one", () => {
114 const first = remark("reviewer", "2026-10-02T12:06:00Z");
115 const late = { ...remark("bot", "2026-10-02T12:06:00Z"), id: "late" };
116 const reported = evaluatePullRequestWatch(watch(), detail(), [first]);
117 const again = evaluatePullRequestWatch(reported.next, detail(), [first, late]);
118 assert.deepEqual(again.changes, [{ kind: "remarks", remarks: [late] }]);
119 assert.deepEqual(again.next.remarkIds, [first.id, "late"]);
120 });
121
122 it("does not treat a failed check read as a rerun", () => {
123 const failed = detail({ checks: [check("lint", "failure")] });
124 const reported = evaluatePullRequestWatch(watch(), failed, noRemarks);
125 assert.equal(reported.changes.length, 1);
126 const unreadable = evaluatePullRequestWatch(reported.next, detail({ checks: [] }), noRemarks);
127 assert.deepEqual(evaluatePullRequestWatch(unreadable.next, failed, noRemarks).changes, []);
128 });
129
130 it("wakes for the pull request's author when the agent is someone else", () => {
131 const contributor = detail({ author: { login: "contributor", name: null, avatarUrl: null } });
132 const reply = remark("contributor", "2026-10-02T12:06:00Z");
133 assert.deepEqual(evaluatePullRequestWatch(watch(), contributor, [reply]).changes, [
134 { kind: "remarks", remarks: [reply] },
135 ]);
136 // Without a viewer, the author is taken to be the agent.
137 const noViewer = detail({ viewer: undefined, author: contributor.author });
138 assert.deepEqual(evaluatePullRequestWatch(watch(), noViewer, [reply]).changes, []);
139 });
140
141 it("reports remarks from others once and never the agent's own", () => {
142 const comments = [
143 remark("agent-user", "2026-10-02T12:05:00Z", "Fixed in the latest push."),
144 remark("macroscope-app[bot]", "2026-10-02T12:06:00Z"),
145 remark("reviewer", "2026-10-02T11:00:00Z", "Older than the watch."),
146 ];
147 const report = evaluatePullRequestWatch(watch(), detail(), comments);
148 assert.deepEqual(report.changes, [{ kind: "remarks", remarks: [comments[1]!] }]);
149 assert.equal(report.next.remarksThrough, "2026-10-02T12:06:00Z");
150 assert.deepEqual(evaluatePullRequestWatch(report.next, detail(), comments).changes, []);
151 });
152
153 it("reports a conflict once, until the branch is clean again", () => {
154 const conflicting = detail({ mergeability: "conflicting" });
155 const first = evaluatePullRequestWatch(watch(), conflicting, noRemarks);
156 assert.deepEqual(first.changes, [{ kind: "conflicting" }]);
157 // GitHub answers "unknown" while it recomputes after a push; that is not a resolution.
158 const recomputing = evaluatePullRequestWatch(
159 first.next,
160 detail({ mergeability: "unknown" }),
161 noRemarks,
162 );
163 assert.deepEqual(
164 evaluatePullRequestWatch(recomputing.next, conflicting, noRemarks).changes,
165 [],
166 );
167 const clean = evaluatePullRequestWatch(first.next, detail(), noRemarks);
168 assert.deepEqual(evaluatePullRequestWatch(clean.next, conflicting, noRemarks).changes, [
169 { kind: "conflicting" },
170 ]);
171 });
172
173 it("does not spend the comment wake limit on check results", () => {
174 const tired = watch({ headSha: "aaaaaaaaaa", wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1 });
175 const result = evaluatePullRequestWatch(
176 tired,
177 detail({ checks: [check("lint", "failure")] }),
178 noRemarks,
179 );
180 assert.isFalse(result.exhausted);
181 assert.equal(result.next.wakes, 0);
182 });
183
184 it("stops after the wake limit unless the head moves", () => {
185 const comments = [remark("reviewer", "2026-10-02T12:10:00Z")];
186 const tired = watch({ headSha: "aaaaaaaaaa", wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1 });
187 assert.isTrue(evaluatePullRequestWatch(tired, detail(), comments).exhausted);
188 const pushed = evaluatePullRequestWatch(tired, detail({ headSha: "cccccccccc" }), comments);
189 assert.isFalse(pushed.exhausted);
190 assert.equal(pushed.next.wakes, 1);
191 });
192});
193
194describe("pullRequestWatchMessage", () => {
195 it("tells the agent what changed and marks failures for the timeline", () => {
196 const report = evaluatePullRequestWatch(
197 watch(),
198 detail({ checks: [check("lint", "failure")] }),
199 [remark("reviewer", "2026-10-02T12:10:00Z", "<!-- bot -->Needs a test.")],
200 );
201 const message = pullRequestWatchMessage({
202 number: 12,
203 url: "https://github.com/o/r/pull/12",
204 baseBranch: "main",
205 headSha: report.next.headSha,
206 report,
207 });
208 assert.include(message.text, "- Checks failed on aaaaaaa:\n - lint https://ci.example/lint");
209 assert.include(message.text, ' - reviewer on src/index.ts: "Needs a test."');
210 assert.include(message.text, "unwatch_pull_request");
211 assert.deepEqual(message.notification, {
212 source: { kind: "monitor" },
213 outcome: "failed",
214 summary: "#12: checks failed, new comments",
215 });
216 });
217});
apps/server/src/orchestration-v2/pullRequestWatch.ts created+209
...@@ -0,0 +1,209 @@
1import type {
2 OrchestrationV2Notification,
3 PullRequestCheck,
4 PullRequestComment,
5 PullRequestDetail,
6 ThreadPullRequestWatch,
7} from "@t3tools/contracts";
8
9/**
10 * Wakes in a row that bring only comments. Check, conflict, or push news resets the count, so
11 * this only stops a chatty bot looping an agent that is replying to it.
12 */
13export const PULL_REQUEST_WATCH_WAKE_LIMIT = 10;
14const LISTED_ITEMS = 10;
15const SNIPPET_LENGTH = 200;
16
17export type PullRequestWatchChange =
18 | { readonly kind: "checks-failed"; readonly failed: ReadonlyArray<PullRequestCheck> }
19 | { readonly kind: "checks-passed"; readonly count: number; readonly required: boolean }
20 | { readonly kind: "remarks"; readonly remarks: ReadonlyArray<PullRequestComment> }
21 | { readonly kind: "conflicting" };
22
23export interface PullRequestWatchReport {
24 /** What the agent has not been told yet. Empty means no wake. */
25 readonly changes: ReadonlyArray<PullRequestWatchChange>;
26 /** The watch to record, whether or not anything is reported. */
27 readonly next: ThreadPullRequestWatch;
28 /** This report spends the last wake before the limit, so watching stops after it. */
29 readonly exhausted: boolean;
30}
31
32// "action-required" is a finished check that needs someone, so the agent hears about it.
33const isFailedCheck = (check: PullRequestCheck) =>
34 check.status === "failure" || check.status === "cancelled" || check.status === "action-required";
35
36/**
37 * Compares a watched pull request with what its agent was last told. Each check is reported as
38 * soon as it fails, so a check that never finishes (an advisory review bot) cannot hold the
39 * news back. "Passed" is reported once the checks the base branch requires all passed, or all
40 * checks where the host marks none required. Remarks count when someone other than the agent's
41 * own account wrote them, so its own replies never wake it. `remarks` is null when the
42 * conversation could not be read; remarks then wait for a later pass.
43 */
44export function evaluatePullRequestWatch(
45 watch: ThreadPullRequestWatch,
46 detail: Pick<PullRequestDetail, "headSha" | "checks" | "mergeability" | "viewer" | "author">,
47 remarks: ReadonlyArray<PullRequestComment> | null,
48): PullRequestWatchReport {
49 const changes: Array<PullRequestWatchChange> = [];
50 const headSha = detail.headSha ?? null;
51 const headMoved = headSha !== watch.headSha;
52
53 // An empty list keeps the last state: a host can answer with one when its check read fails.
54 let failedChecks = headMoved ? [] : watch.failedChecks;
55 let passed = headMoved ? false : watch.passed;
56 if (detail.checks.length > 0) {
57 const failed = detail.checks.filter(isFailedCheck);
58 const newlyFailed = failed.filter((check) => !failedChecks.includes(check.name));
59 if (newlyFailed.length > 0) changes.push({ kind: "checks-failed", failed: newlyFailed });
60 // A check that runs again leaves the list, so a rerun that fails again is reported.
61 failedChecks = failed.map((check) => check.name);
62
63 const required = detail.checks.filter((check) => check.required === true);
64 const gate = required.length > 0 ? required : detail.checks;
65 const passedNow = gate.every((check) => check.status !== "pending" && !isFailedCheck(check));
66 if (passedNow && !passed) {
67 changes.push({ kind: "checks-passed", count: gate.length, required: required.length > 0 });
68 }
69 passed = passedNow;
70 }
71
72 const own = (detail.viewer ?? detail.author?.login)?.toLowerCase();
73 const through = Date.parse(watch.remarksThrough);
74 // GitHub times are per second, so remarks at the boundary time are told apart by ID.
75 const fresh = (remarks ?? []).filter((remark) => {
76 const at = Date.parse(remark.createdAt);
77 return (
78 (at > through || (at === through && !watch.remarkIds.includes(remark.id))) &&
79 remark.author?.login.toLowerCase() !== own
80 );
81 });
82 if (fresh.length > 0) changes.push({ kind: "remarks", remarks: fresh });
83 const latest = Math.max(through, ...fresh.map((remark) => Date.parse(remark.createdAt)));
84 const atLatest = fresh.filter((remark) => Date.parse(remark.createdAt) === latest);
85 const remarksThrough = latest === through ? watch.remarksThrough : atLatest[0]!.createdAt;
86 const remarkIds = [
87 ...(latest === through ? watch.remarkIds : []),
88 ...atLatest.map((remark) => remark.id),
89 ];
90
91 if (detail.mergeability === "conflicting" && !watch.conflicting) {
92 changes.push({ kind: "conflicting" });
93 }
94 // "unknown" is GitHub still computing after a push; only a clean answer clears a conflict.
95 const conflicting =
96 detail.mergeability === "unknown" ? watch.conflicting : detail.mergeability === "conflicting";
97
98 const commentsOnly = changes.length > 0 && changes.every((change) => change.kind === "remarks");
99 const progress = headMoved || (changes.length > 0 && !commentsOnly);
100 const wakes = (progress ? 0 : watch.wakes) + (commentsOnly ? 1 : 0);
101 return {
102 changes,
103 next: {
104 startedAt: watch.startedAt,
105 headSha,
106 failedChecks,
107 passed,
108 remarksThrough,
109 remarkIds,
110 conflicting,
111 wakes,
112 },
113 exhausted: commentsOnly && wakes >= PULL_REQUEST_WATCH_WAKE_LIMIT,
114 };
115}
116
117function snippet(body: string): string {
118 const text = body
119 .replaceAll(/<!--[\s\S]*?-->/g, " ")
120 .replaceAll(/\s+/g, " ")
121 .trim();
122 return text.length <= SNIPPET_LENGTH ? text : `${text.slice(0, SNIPPET_LENGTH - 3)}...`;
123}
124
125function listed<T>(items: ReadonlyArray<T>, line: (item: T) => string): Array<string> {
126 const lines = items.slice(0, LISTED_ITEMS).map(line);
127 if (items.length > LISTED_ITEMS) lines.push(` - and ${items.length - LISTED_ITEMS} more`);
128 return lines;
129}
130
131function changeLines(
132 change: PullRequestWatchChange,
133 context: { readonly baseBranch: string; readonly commit: string },
134): Array<string> {
135 switch (change.kind) {
136 case "checks-failed":
137 return [
138 `- Checks failed${context.commit}:`,
139 ...listed(
140 change.failed,
141 (check) =>
142 ` - ${check.name}${check.status === "failure" ? "" : ` (${check.status})`}${check.url ? ` ${check.url}` : ""}`,
143 ),
144 ];
145 case "checks-passed":
146 return [
147 `- All ${change.count} ${change.required ? "required " : ""}${change.count === 1 ? "check" : "checks"} passed${context.commit}.`,
148 ];
149 case "remarks":
150 return [
151 `- ${change.remarks.length} new ${change.remarks.length === 1 ? "comment" : "comments"}:`,
152 ...listed(change.remarks, (remark) => {
153 const where = remark.path === null ? "" : ` on ${remark.path}`;
154 const body = snippet(remark.body);
155 const said = body.length === 0 ? (remark.reviewState ?? "reviewed") : `"${body}"`;
156 return ` - ${remark.author?.login ?? "someone"}${where}: ${said}${remark.url ? ` ${remark.url}` : ""}`;
157 }),
158 ];
159 case "conflicting":
160 return [`- The branch now conflicts with ${context.baseBranch}.`];
161 }
162}
163
164const SUMMARY: Record<PullRequestWatchChange["kind"], string> = {
165 "checks-failed": "checks failed",
166 "checks-passed": "checks passed",
167 remarks: "new comments",
168 conflicting: "merge conflict",
169};
170
171/** The wake the agent reads and the timeline notification the user sees. */
172export function pullRequestWatchMessage(input: {
173 readonly number: number;
174 readonly url: string;
175 readonly baseBranch: string;
176 readonly headSha: string | null;
177 readonly report: PullRequestWatchReport;
178}): { readonly text: string; readonly notification: OrchestrationV2Notification } {
179 const { changes, exhausted } = input.report;
180 const context = {
181 baseBranch: input.baseBranch,
182 commit: input.headSha === null ? "" : ` on ${input.headSha.slice(0, 7)}`,
183 };
184 const text = [
185 `Update on pull request #${input.number} (${input.url}), which T3 Code is watching for you:`,
186 ...changes.flatMap((change) => changeLines(change, context)),
187 "",
188 exhausted
189 ? `T3 Code stopped watching after ${PULL_REQUEST_WATCH_WAKE_LIMIT} comment-only updates in a row. Call watch_pull_request to watch it again.`
190 : "Look into each item and act on it as your task requires. T3 Code keeps watching and wakes you on the next change, so end your turn when you are done. Call unwatch_pull_request when you no longer need updates.",
191 ].join("\n");
192 const failed = changes.some(
193 (change) => change.kind === "checks-failed" || change.kind === "conflicting",
194 );
195 const summary = changes.map((change) => SUMMARY[change.kind]);
196 if (exhausted) summary.push("stopped watching");
197 return {
198 text,
199 notification: {
200 source: { kind: "monitor" },
201 outcome: failed
202 ? "failed"
203 : changes.every((change) => change.kind === "checks-passed")
204 ? "completed"
205 : "updated",
206 summary: `#${input.number}: ${summary.join(", ")}`,
207 },
208 };
209}
apps/server/src/orchestration-v2/runtimeLayer.test.ts+297
...@@ -17,6 +17,7 @@ import {...@@ -17,6 +17,7 @@ import {
17 type ModelSelection,17 type ModelSelection,
18 type OrchestrationV2Run,18 type OrchestrationV2Run,
19 ProjectId,19 ProjectId,
20 type PullRequestDetail,
20 ProviderDriverKind,21 ProviderDriverKind,
21 ProviderInstanceId,22 ProviderInstanceId,
22 ProviderThreadId,23 ProviderThreadId,
...@@ -58,6 +59,9 @@ import * as EffectOutbox from "./EffectOutbox.ts";...@@ -58,6 +59,9 @@ import * as EffectOutbox from "./EffectOutbox.ts";
58import * as EventSink from "./EventSink.ts";59import * as EventSink from "./EventSink.ts";
59import * as ProviderRuntimeRecoveryService from "./ProviderRuntimeRecoveryService.ts";60import * as ProviderRuntimeRecoveryService from "./ProviderRuntimeRecoveryService.ts";
60import * as ProjectionMaintenance from "./ProjectionMaintenance.ts";61import * as ProjectionMaintenance from "./ProjectionMaintenance.ts";
62import * as ProjectionStore from "./ProjectionStore.ts";
63import * as PullRequestWatchReactor from "./PullRequestWatchReactor.ts";
64import * as PullRequestService from "../pullRequest/PullRequestService.ts";
61import * as ProjectStore from "./ProjectStore.ts";65import * as ProjectStore from "./ProjectStore.ts";
62import type { ProviderAdapterV2SessionRuntime, ProviderAdapterV2Shape } from "./ProviderAdapter.ts";66import type { ProviderAdapterV2SessionRuntime, ProviderAdapterV2Shape } from "./ProviderAdapter.ts";
63import * as ProviderSessionManager from "./ProviderSessionManager.ts";67import * as ProviderSessionManager from "./ProviderSessionManager.ts";
...@@ -212,6 +216,7 @@ const TestLayer = Layer.mergeAll(...@@ -212,6 +216,7 @@ const TestLayer = Layer.mergeAll(
212 OrchestrationV2LayerLive,216 OrchestrationV2LayerLive,
213 OrchestrationV2EventSinkLayerLive,217 OrchestrationV2EventSinkLayerLive,
214 ProjectStore.layer,218 ProjectStore.layer,
219 ProjectionStore.layer,
215 EffectOutbox.layer,220 EffectOutbox.layer,
216 ThreadCommandExecutor.layer,221 ThreadCommandExecutor.layer,
217).pipe(222).pipe(
...@@ -2148,6 +2153,298 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => {...@@ -2148,6 +2153,298 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => {
2148 }),2153 }),
2149 );2154 );
21502155
2156 it.effect("starts, records, and stops a pull request watch", () =>
2157 Effect.gen(function* () {
2158 const orchestrator = yield* Orchestrator.OrchestratorV2;
2159 const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2;
2160 const threadId = ThreadId.make("runtime-pull-request-watch");
2161 yield* orchestrator.dispatch({
2162 type: "thread.create",
2163 createdBy: "user",
2164 creationSource: "web",
2165 commandId: CommandId.make("pr-watch-create"),
2166 threadId,
2167 projectId: ProjectId.make("pr-watch-project"),
2168 title: "Watch",
2169 modelSelection,
2170 runtimeMode: "full-access",
2171 interactionMode: "default",
2172 branch: null,
2173 worktreePath: null,
2174 });
2175 const key = { host: "github.com", repository: "pingdotgg/t3code", number: 7 };
2176 const url = "https://github.com/pingdotgg/t3code/pull/7";
2177 const watchOf = Effect.map(
2178 orchestrator.getThreadShell(threadId),
2179 (thread) => thread?.pullRequests?.[0]?.watch,
2180 );
2181
2182 // Watching an unlinked pull request links it in the same command.
2183 yield* orchestrator.dispatch({
2184 type: "thread.pull-request.watch",
2185 commandId: CommandId.make("pr-watch-start"),
2186 threadId,
2187 ...key,
2188 watching: true,
2189 link: { url, source: "agent" },
2190 });
2191 assert.equal(
2192 (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.source,
2193 "agent",
2194 );
2195 const started = yield* watchOf;
2196 assert.isDefined(started);
2197 if (started === undefined) return;
2198
2199 // A legacy client re-linking the same pull request keeps its watch.
2200 yield* orchestrator.dispatch({
2201 type: "thread.metadata.update",
2202 commandId: CommandId.make("pr-watch-legacy-relink"),
2203 threadId,
2204 linkedPullRequest: { projectId: ProjectId.make("pr-watch-project"), ...key, url },
2205 });
2206 assert.deepEqual(yield* watchOf, started);
2207
2208 const recorded = { ...started, headSha: "abc123", failedChecks: ["lint"], wakes: 1 };
2209 yield* orchestrator.dispatch({
2210 type: "thread.pull-request-watch.sync",
2211 commandId: CommandId.make("pr-watch-record"),
2212 threadId,
2213 ...key,
2214 startedAt: started.startedAt,
2215 watch: recorded,
2216 });
2217 assert.deepEqual(yield* watchOf, recorded);
2218 assert.isTrue((yield* maintenance.rebuild).valid);
2219 assert.deepEqual(yield* watchOf, recorded);
2220
2221 yield* orchestrator.dispatch({
2222 type: "thread.pull-request.watch",
2223 commandId: CommandId.make("pr-watch-stop"),
2224 threadId,
2225 ...key,
2226 watching: false,
2227 });
2228 // A wake read before the stop must neither wake the agent nor bring the watch back.
2229 const late = yield* orchestrator
2230 .dispatch({
2231 type: "thread.pull-request-watch.sync",
2232 commandId: CommandId.make("pr-watch-late-record"),
2233 threadId,
2234 ...key,
2235 startedAt: started.startedAt,
2236 watch: { ...recorded, wakes: 2 },
2237 wake: {
2238 messageId: MessageId.make("pr-watch-late-wake"),
2239 text: "Update",
2240 notification: { source: { kind: "monitor" }, outcome: "updated", summary: "#7" },
2241 },
2242 })
2243 .pipe(Effect.flip);
2244 assert.equal(late._tag, "OrchestratorDispatchError");
2245 assert.isUndefined(yield* watchOf);
2246 const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]);
2247 assert.deepEqual(messages, []);
2248 }),
2249 );
2250
2251 it.effect("ends a watch it cannot read, and tells the agent", () =>
2252 Effect.gen(function* () {
2253 const orchestrator = yield* Orchestrator.OrchestratorV2;
2254 const threadId = ThreadId.make("runtime-pull-request-watch-unreadable");
2255 const projectId = ProjectId.make("pr-watch-unreadable-project");
2256 yield* seedProject({
2257 projectId,
2258 title: "Watch unreadable",
2259 workspaceRoot: "/workspace/watch-unreadable",
2260 defaultModelSelection: null,
2261 createdAt: "2026-10-01T00:00:00.000Z",
2262 });
2263 yield* orchestrator.dispatch({
2264 type: "thread.create",
2265 createdBy: "user",
2266 creationSource: "web",
2267 commandId: CommandId.make("pr-watch-unreadable-create"),
2268 threadId,
2269 projectId,
2270 title: "Watch unreadable",
2271 modelSelection,
2272 runtimeMode: "full-access",
2273 interactionMode: "default",
2274 branch: null,
2275 worktreePath: null,
2276 });
2277 yield* orchestrator.dispatch({
2278 type: "thread.pull-request.watch",
2279 commandId: CommandId.make("pr-watch-unreadable-start"),
2280 threadId,
2281 host: "github.com",
2282 repository: "pingdotgg/t3code",
2283 number: 8,
2284 watching: true,
2285 link: { url: "https://github.com/pingdotgg/t3code/pull/8", source: "agent" },
2286 });
2287 const reactor = yield* PullRequestWatchReactor.make.pipe(
2288 Effect.provide(
2289 Layer.mergeAll(
2290 NodeServices.layer,
2291 Layer.mock(PullRequestService.PullRequestService)({
2292 detail: () => Effect.die("host unreachable"),
2293 activity: () => Effect.die("host unreachable"),
2294 }),
2295 ),
2296 ),
2297 );
2298 for (let pass = 0; pass < 15; pass += 1) yield* reactor.sweep;
2299
2300 const thread = yield* orchestrator.getThreadShell(threadId);
2301 assert.isUndefined(thread?.pullRequests?.[0]?.watch);
2302 const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]);
2303 assert.deepEqual(
2304 messages.flatMap((message) => message.notification?.summary ?? []),
2305 ["#8: stopped watching, could not read it"],
2306 );
2307 }),
2308 );
2309
2310 it.effect("wakes a watched thread once for failed checks and a review comment", () =>
2311 Effect.gen(function* () {
2312 const orchestrator = yield* Orchestrator.OrchestratorV2;
2313 const threadId = ThreadId.make("runtime-pull-request-watch-wake");
2314 const projectId = ProjectId.make("pr-watch-wake-project");
2315 yield* seedProject({
2316 projectId,
2317 title: "Watch wake",
2318 workspaceRoot: "/workspace/watch",
2319 defaultModelSelection: null,
2320 createdAt: "2026-10-01T00:00:00.000Z",
2321 });
2322 yield* orchestrator.dispatch({
2323 type: "thread.create",
2324 createdBy: "user",
2325 creationSource: "web",
2326 commandId: CommandId.make("pr-watch-wake-create"),
2327 threadId,
2328 projectId,
2329 title: "Watch wake",
2330 modelSelection,
2331 runtimeMode: "full-access",
2332 interactionMode: "default",
2333 branch: null,
2334 worktreePath: null,
2335 });
2336 const key = { host: "github.com", repository: "pingdotgg/t3code", number: 7 };
2337 const url = "https://github.com/pingdotgg/t3code/pull/7";
2338 yield* orchestrator.dispatch({
2339 type: "thread.pull-request.link",
2340 commandId: CommandId.make("pr-watch-wake-link"),
2341 threadId,
2342 ...key,
2343 url,
2344 source: "agent",
2345 });
2346 yield* orchestrator.dispatch({
2347 type: "thread.pull-request.watch",
2348 commandId: CommandId.make("pr-watch-wake-start"),
2349 threadId,
2350 ...key,
2351 watching: true,
2352 });
2353
2354 const at = "2026-10-02T12:00:00.000Z";
2355 const detail: PullRequestDetail = {
2356 provider: "github",
2357 capabilities: {
2358 diff: true,
2359 comment: true,
2360 actions: [],
2361 mergeMethods: [],
2362 search: false,
2363 review: { inlineComment: false, reply: false, resolve: false, verdicts: [] },
2364 reviewers: { request: false, listCandidates: false },
2365 },
2366 viewerPermissions: {
2367 actions: [],
2368 comment: true,
2369 resolve: true,
2370 verdicts: [],
2371 requestReviewers: false,
2372 },
2373 projectId,
2374 projectTitle: "Watch wake",
2375 workspaceRoot: "/workspace/watch",
2376 repository: key.repository,
2377 number: key.number,
2378 title: "Watched pull request",
2379 body: "",
2380 url,
2381 author: { login: "agent-user", name: null, avatarUrl: null },
2382 state: "open",
2383 isDraft: false,
2384 mergeability: "mergeable",
2385 additions: 1,
2386 deletions: 0,
2387 changedFiles: 1,
2388 headBranch: "feature",
2389 headSha: "abc1234def",
2390 baseBranch: "main",
2391 createdAt: at,
2392 updatedAt: at,
2393 mergedAt: null,
2394 closedAt: null,
2395 reviewers: [],
2396 labels: [],
2397 checks: [{ name: "lint", status: "failure", description: null, url: null }],
2398 mergeCapabilities: { merge: true, squash: true, rebase: true },
2399 viewer: "agent-user",
2400 };
2401 const reactor = yield* PullRequestWatchReactor.make.pipe(
2402 Effect.provide(
2403 Layer.mergeAll(
2404 NodeServices.layer,
2405 Layer.mock(PullRequestService.PullRequestService)({
2406 detail: () => Effect.succeed(detail),
2407 activity: () =>
2408 Effect.succeed({
2409 comments: [
2410 {
2411 id: "review-1",
2412 kind: "review-comment",
2413 author: { login: "reviewer", name: null, avatarUrl: null },
2414 body: "One more thing.",
2415 createdAt: "2999-01-01T00:00:00.000Z",
2416 url: null,
2417 path: "src/index.ts",
2418 reviewState: null,
2419 },
2420 ],
2421 commentCount: 1,
2422 commentsTruncated: false,
2423 reviewThreads: [],
2424 commits: [],
2425 }),
2426 }),
2427 ),
2428 ),
2429 );
2430 yield* reactor.sweep;
2431 yield* reactor.sweep;
2432
2433 const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]);
2434 assert.deepEqual(
2435 messages.flatMap((message) =>
2436 message.notification === undefined ? [] : [message.notification.summary],
2437 ),
2438 ["#7: checks failed, new comments"],
2439 );
2440 const watch = (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch;
2441 assert.deepEqual(
2442 { headSha: watch?.headSha, failedChecks: watch?.failedChecks, wakes: watch?.wakes },
2443 { headSha: "abc1234def", failedChecks: ["lint"], wakes: 0 },
2444 );
2445 }),
2446 );
2447
2151 it.effect("persists rejected command receipts across retries", () =>2448 it.effect("persists rejected command receipts across retries", () =>
2152 Effect.gen(function* () {2449 Effect.gen(function* () {
2153 const orchestrator = yield* Orchestrator.OrchestratorV2;2450 const orchestrator = yield* Orchestrator.OrchestratorV2;
apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts+1
...@@ -153,6 +153,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray<Thread...@@ -153,6 +153,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray<Thread
153 case "thread.pull-request.link":153 case "thread.pull-request.link":
154 case "thread.pull-request.unlink":154 case "thread.pull-request.unlink":
155 case "thread.pull-request-link.sync":155 case "thread.pull-request-link.sync":
156 case "thread.pull-request.watch":
156 case "thread.pull-request.sync":157 case "thread.pull-request.sync":
157 case "thread.title.regeneration.complete":158 case "thread.title.regeneration.complete":
158 case "thread.runtime-mode.set":159 case "thread.runtime-mode.set":
apps/server/src/provider/RuntimeInstructions.ts+1-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1const PULL_REQUEST_LINKING_INSTRUCTIONS = `<pull_request_linking>1const PULL_REQUEST_LINKING_INSTRUCTIONS = `<pull_request_linking>
2When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.2When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher.
3</pull_request_linking>`;3</pull_request_linking>`;
44
5/**5/**
apps/server/src/pullRequest/GitHubPullRequestCli.ts+2-2
...@@ -52,7 +52,7 @@ import {...@@ -52,7 +52,7 @@ import {
52 decodePullRequestActivityJson,52 decodePullRequestActivityJson,
53 decodePullRequestDetailJson,53 decodePullRequestDetailJson,
54 decodePullRequestCoreJson,54 decodePullRequestCoreJson,
55 PULL_REQUEST_CORE_GRAPHQL_QUERY,55 pullRequestCoreGraphQlQuery,
56 type GitHubPullRequestCore,56 type GitHubPullRequestCore,
57 type GitHubPullRequestSummary,57 type GitHubPullRequestSummary,
58 decodePullRequestPreviewJson,58 decodePullRequestPreviewJson,
...@@ -1595,7 +1595,7 @@ export const make = Effect.gen(function* () {...@@ -1595,7 +1595,7 @@ export const make = Effect.gen(function* () {
1595 ["-F", `number=${input.number}`],1595 ["-F", `number=${input.number}`],
1596 ["-f", `headRef=refs/pull/${input.number}/head`],1596 ["-f", `headRef=refs/pull/${input.number}/head`],
1597 ],1597 ],
1598 query: PULL_REQUEST_CORE_GRAPHQL_QUERY,1598 query: pullRequestCoreGraphQlQuery(input.host),
1599 decode: decodePullRequestCoreJson,1599 decode: decodePullRequestCoreJson,
1600 }),1600 }),
1601 ),1601 ),
apps/server/src/pullRequest/PullRequestProvider.ts+2
...@@ -213,6 +213,8 @@ export interface ProviderChangeRequestStat {...@@ -213,6 +213,8 @@ export interface ProviderChangeRequestStat {
213}213}
214214
215export interface ProviderChangeRequestDetail extends ProviderChangeRequest {215export interface ProviderChangeRequestDetail extends ProviderChangeRequest {
216 /** The head commit, where the host's detail read reports it. */
217 readonly headSha?: string | null;
216 readonly body: string;218 readonly body: string;
217 readonly changedFiles: number;219 readonly changedFiles: number;
218 readonly mergedAt: string | null;220 readonly mergedAt: string | null;
apps/server/src/pullRequest/PullRequestService.ts+1
...@@ -1697,6 +1697,7 @@ export const make = Effect.gen(function* () {...@@ -1697,6 +1697,7 @@ export const make = Effect.gen(function* () {
1697 ...(changeRequest.headRepositoryNameWithOwner === undefined1697 ...(changeRequest.headRepositoryNameWithOwner === undefined
1698 ? {}1698 ? {}
1699 : { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }),1699 : { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }),
1700 ...(changeRequest.headSha ? { headSha: changeRequest.headSha } : {}),
1700 baseBranch: changeRequest.baseBranch,1701 baseBranch: changeRequest.baseBranch,
1701 createdAt: changeRequest.createdAt,1702 createdAt: changeRequest.createdAt,
1702 updatedAt: changeRequest.updatedAt,1703 updatedAt: changeRequest.updatedAt,
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+20
...@@ -26,6 +26,7 @@ import {...@@ -26,6 +26,7 @@ import {
26 decodeWorkflowRunApprovalsJson,26 decodeWorkflowRunApprovalsJson,
27 reviewThreadConversation,27 reviewThreadConversation,
28 REVIEW_THREADS_GRAPHQL_QUERY,28 REVIEW_THREADS_GRAPHQL_QUERY,
29 pullRequestCoreGraphQlQuery,
29 pullRequestSearchGraphQlQuery,30 pullRequestSearchGraphQlQuery,
30} from "./gitHubPullRequestJson.ts";31} from "./gitHubPullRequestJson.ts";
3132
...@@ -285,6 +286,25 @@ describe("pull request detail decoding", () => {...@@ -285,6 +286,25 @@ describe("pull request detail decoding", () => {
285 ]);286 ]);
286 });287 });
287288
289 it("keeps what branch protection requires, and asks for it on github.com only", () => {
290 const raw = JSON.parse(detailJson) as Record<string, unknown>;
291 const detail = expectSuccess(
292 decodePullRequestDetailJson(
293 JSON.stringify({
294 ...raw,
295 statusCheckRollup: [
296 { __typename: "CheckRun", name: "test", status: "IN_PROGRESS", isRequired: true },
297 { __typename: "StatusContext", context: "bot", state: "PENDING", isRequired: false },
298 { __typename: "StatusContext", context: "legacy", state: "SUCCESS" },
299 ],
300 }),
301 ),
302 );
303 expect(detail.checks.map((check) => check.required)).toEqual([true, false, undefined]);
304 expect(pullRequestCoreGraphQlQuery("github.com")).toContain("isRequired");
305 expect(pullRequestCoreGraphQlQuery("github.example.com")).not.toContain("isRequired");
306 });
307
288 it("keeps a workflow waiting for approval out of the passing state", () => {308 it("keeps a workflow waiting for approval out of the passing state", () => {
289 const raw = JSON.parse(detailJson) as Record<string, unknown>;309 const raw = JSON.parse(detailJson) as Record<string, unknown>;
290 const detail = expectSuccess(310 const detail = expectSuccess(
apps/server/src/pullRequest/gitHubPullRequestJson.ts+15-4
...@@ -93,6 +93,8 @@ const RawCheckSchema = Schema.Struct({...@@ -93,6 +93,8 @@ const RawCheckSchema = Schema.Struct({
93 workflowName: Schema.optional(Schema.NullOr(Schema.String)),93 workflowName: Schema.optional(Schema.NullOr(Schema.String)),
94 startedAt: Schema.optional(Schema.NullOr(Schema.String)),94 startedAt: Schema.optional(Schema.NullOr(Schema.String)),
95 completedAt: Schema.optional(Schema.NullOr(Schema.String)),95 completedAt: Schema.optional(Schema.NullOr(Schema.String)),
96 /** Branch protection requires this check; read by the detail query on github.com only. */
97 isRequired: Schema.optional(Schema.NullOr(Schema.Boolean)),
96});98});
9799
98const RawListItemSchema = Schema.Struct({100const RawListItemSchema = Schema.Struct({
...@@ -706,8 +708,15 @@ export const PULL_REQUEST_LIST_JSON_FIELDS =...@@ -706,8 +708,15 @@ export const PULL_REQUEST_LIST_JSON_FIELDS =
706708
707export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`;709export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`;
708710
709/** Pull refs let the comparison share the detail read without first resolving a fork branch. */711/**
710export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) {712 * Pull refs let the comparison share the detail read without first resolving a fork branch.
713 * `isRequired` is asked for on github.com only: an older Enterprise server may not know it, and
714 * an unknown field fails the whole read.
715 */
716export const pullRequestCoreGraphQlQuery = (host: string) => {
717 const required =
718 host.toLowerCase() === "github.com" ? " isRequired(pullRequestNumber: $number)" : "";
719 return `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) {
711 repository(owner: $owner, name: $name) {720 repository(owner: $owner, name: $name) {
712 mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission721 mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission
713 pullRequest(number: $number) {722 pullRequest(number: $number) {
...@@ -727,9 +736,9 @@ export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: St...@@ -727,9 +736,9 @@ export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: St
727 nodes { commit { statusCheckRollup { contexts(first: 100) {736 nodes { commit { statusCheckRollup { contexts(first: 100) {
728 nodes {737 nodes {
729 __typename738 __typename
730 ... on StatusContext { context state targetUrl createdAt description }739 ... on StatusContext { context state targetUrl createdAt description${required} }
731 ... on CheckRun {740 ... on CheckRun {
732 name status conclusion startedAt completedAt detailsUrl741 name status conclusion startedAt completedAt detailsUrl${required}
733 checkSuite { workflowRun { workflow { name } } }742 checkSuite { workflowRun { workflow { name } } }
734 }743 }
735 }744 }
...@@ -739,6 +748,7 @@ export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: St...@@ -739,6 +748,7 @@ export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: St
739 }748 }
740 }749 }
741}`;750}`;
751};
742752
743export const PULL_REQUEST_PREVIEW_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {753export const PULL_REQUEST_PREVIEW_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
744 repository(owner: $owner, name: $name) {754 repository(owner: $owner, name: $name) {
...@@ -1468,6 +1478,7 @@ function toCheckEntries(...@@ -1468,6 +1478,7 @@ function toCheckEntries(
1468 status: toCheckStatus(check),1478 status: toCheckStatus(check),
1469 description: trimmed(check.description),1479 description: trimmed(check.description),
1470 url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl),1480 url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl),
1481 ...(typeof check.isRequired === "boolean" ? { required: check.isRequired } : {}),
1471 },1482 },
1472 workflowName: trimmed(check.workflowName),1483 workflowName: trimmed(check.workflowName),
1473 at: realTimestamp(check.completedAt) ?? realTimestamp(check.startedAt),1484 at: realTimestamp(check.completedAt) ?? realTimestamp(check.startedAt),
apps/server/src/server.ts+11
...@@ -4,6 +4,7 @@ import * as Random from "effect/Random";...@@ -4,6 +4,7 @@ import * as Random from "effect/Random";
4import * as Semaphore from "effect/Semaphore";4import * as Semaphore from "effect/Semaphore";
5import * as StorageCleanup from "./storageCleanup.ts";5import * as StorageCleanup from "./storageCleanup.ts";
6import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts";6import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts";
7import * as PullRequestWatchReactor from "./orchestration-v2/PullRequestWatchReactor.ts";
7// @effect-diagnostics nodeBuiltinImport:off8// @effect-diagnostics nodeBuiltinImport:off
8import * as NodeHttp from "node:http";9import * as NodeHttp from "node:http";
910
...@@ -521,6 +522,16 @@ const RuntimeCoreDependenciesBaseLive = Layer.mergeAll(...@@ -521,6 +522,16 @@ const RuntimeCoreDependenciesBaseLive = Layer.mergeAll(
521 Layer.provide(PullRequestServiceLive),522 Layer.provide(PullRequestServiceLive),
522 Layer.provide(ProjectionStoreV2.layer),523 Layer.provide(ProjectionStoreV2.layer),
523 ),524 ),
525 Layer.effectDiscard(
526 Effect.gen(function* () {
527 const service = yield* PullRequestWatchReactor.PullRequestWatchReactor;
528 yield* service.start();
529 }),
530 ).pipe(
531 Layer.provide(PullRequestWatchReactor.layer),
532 Layer.provide(PullRequestServiceLive),
533 Layer.provide(ProjectionStoreV2.layer),
534 ),
524 // Subscribes to `account.rate-limits.updated` so usage bars track live535 // Subscribes to `account.rate-limits.updated` so usage bars track live
525 // telemetry instead of waiting for the next status probe.536 // telemetry instead of waiting for the next status probe.
526 ProviderUsageLimitsIngestionLive,537 ProviderUsageLimitsIngestionLive,
apps/web/src/components/chat/MessagesTimeline.tsx+2
...@@ -3630,6 +3630,8 @@ function toolGroupSummaryIconName(...@@ -3630,6 +3630,8 @@ function toolGroupSummaryIconName(
3630 case "link-pr":3630 case "link-pr":
3631 case "unlink-pr":3631 case "unlink-pr":
3632 case "list-prs":3632 case "list-prs":
3633 case "watch-pr":
3634 case "unwatch-pr":
3633 return "pull-request";3635 return "pull-request";
3634 case "read":3636 case "read":
3635 return "eye";3637 return "eye";
apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx+53-4
...@@ -3,7 +3,14 @@ import {...@@ -3,7 +3,14 @@ import {
3 resolveThreadPullRequestChains,3 resolveThreadPullRequestChains,
4 visibleThreadPullRequests,4 visibleThreadPullRequests,
5} from "@t3tools/shared/threadPullRequests";5} from "@t3tools/shared/threadPullRequests";
6import { ArrowUpRightIcon, LinkIcon, MoreHorizontalIcon, PlusIcon } from "lucide-react";6import {
7 ArrowUpRightIcon,
8 EyeIcon,
9 EyeOffIcon,
10 LinkIcon,
11 MoreHorizontalIcon,
12 PlusIcon,
13} from "lucide-react";
7import { useCallback, useMemo } from "react";14import { useCallback, useMemo } from "react";
815
9import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";16import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
...@@ -68,14 +75,19 @@ function LinkRow({...@@ -68,14 +75,19 @@ function LinkRow({
68 line,75 line,
69 threadRef,76 threadRef,
70 onUnlink,77 onUnlink,
78 onSetWatching,
71}: {79}: {
72 line: PullRequestListLine;80 line: PullRequestListLine;
73 threadRef: ScopedThreadRef;81 threadRef: ScopedThreadRef;
74 onUnlink: (link: ThreadPullRequestLink) => void;82 onUnlink: (link: ThreadPullRequestLink) => void;
83 /** Null when the environment cannot watch pull requests. */
84 onSetWatching: ((link: ThreadPullRequestLink, watching: boolean) => void) | null;
75}) {85}) {
76 const openPrLink = useOpenPrLink(threadRef);86 const openPrLink = useOpenPrLink(threadRef);
77 const { link, depth, stack } = line;87 const { link, depth, stack } = line;
78 const snapshot = link.snapshot;88 const snapshot = link.snapshot;
89 const open = snapshot === null || snapshot.state === "open";
90 const watching = link.watch !== undefined;
79 return (91 return (
80 <div92 <div
81 className={cn(PULL_REQUEST_ROW_CLASS, "relative hover:bg-accent/60")}93 className={cn(PULL_REQUEST_ROW_CLASS, "relative hover:bg-accent/60")}
...@@ -116,10 +128,21 @@ function LinkRow({...@@ -116,10 +128,21 @@ function LinkRow({
116 }128 }
117 title={snapshot?.title ?? link.repository}129 title={snapshot?.title ?? link.repository}
118 signals={130 signals={
119 snapshot?.state === "open" ? (131 open ? (
120 <>132 <>
121 {snapshot.checksState ? <ChecksGlyph state={snapshot.checksState} /> : null}133 {watching ? (
122 {snapshot.reviewDecision ? (134 <Tooltip>
135 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>
136 <EyeIcon role="img" aria-label="Watching" className="size-3.5" />
137 </TooltipTrigger>
138 <TooltipPopup>
139 Watching: the agent wakes when checks finish, someone comments, or the branch
140 conflicts
141 </TooltipPopup>
142 </Tooltip>
143 ) : null}
144 {snapshot?.checksState ? <ChecksGlyph state={snapshot.checksState} /> : null}
145 {snapshot?.reviewDecision ? (
123 <PullRequestReviewDecisionGlyph decision={snapshot.reviewDecision} />146 <PullRequestReviewDecisionGlyph decision={snapshot.reviewDecision} />
124 ) : null}147 ) : null}
125 </>148 </>
...@@ -220,6 +243,12 @@ function LinkRow({...@@ -220,6 +243,12 @@ function LinkRow({
220 <ArrowUpRightIcon className="size-3.5" />243 <ArrowUpRightIcon className="size-3.5" />
221 Open244 Open
222 </MenuItem>245 </MenuItem>
246 {onSetWatching !== null && open ? (
247 <MenuItem onClick={() => onSetWatching(link, !watching)}>
248 {watching ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
249 {watching ? "Stop watching" : "Watch for changes"}
250 </MenuItem>
251 ) : null}
223 <MenuItem onClick={() => onUnlink(link)}>252 <MenuItem onClick={() => onUnlink(link)}>
224 <PullRequestGlyph.unlink className="size-3.5" />253 <PullRequestGlyph.unlink className="size-3.5" />
225 {link.source === "stack" ? "Dismiss from thread" : "Unlink from thread"}254 {link.source === "stack" ? "Dismiss from thread" : "Unlink from thread"}
...@@ -248,6 +277,10 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread...@@ -248,6 +277,10 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread
248 const thread = useThreadShell(threadRef);277 const thread = useThreadShell(threadRef);
249 const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]);278 const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]);
250 const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true });279 const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true });
280 const watch = useAtomCommand(threadEnvironment.watchPullRequest, { reportFailure: true });
281 const supportsWatch =
282 useServerConfigs().get(threadRef.environmentId)?.environment.capabilities
283 .threadPullRequestWatch === true;
251 const links = useMemo(() => visibleThreadPullRequests(thread?.pullRequests ?? []), [thread]);284 const links = useMemo(() => visibleThreadPullRequests(thread?.pullRequests ?? []), [thread]);
252 const lines = useMemo(() => pullRequestListLines(resolveThreadPullRequestChains(links)), [links]);285 const lines = useMemo(() => pullRequestListLines(resolveThreadPullRequestChains(links)), [links]);
253 const handleUnlink = useCallback(286 const handleUnlink = useCallback(
...@@ -264,6 +297,21 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread...@@ -264,6 +297,21 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread
264 },297 },
265 [threadRef, unlink],298 [threadRef, unlink],
266 );299 );
300 const handleSetWatching = useCallback(
301 (link: ThreadPullRequestLink, watching: boolean) => {
302 void watch({
303 environmentId: threadRef.environmentId,
304 input: {
305 threadId: threadRef.threadId,
306 host: link.host,
307 repository: link.repository,
308 number: link.number,
309 watching,
310 },
311 });
312 },
313 [threadRef, watch],
314 );
267 const openCount = useMemo(315 const openCount = useMemo(
268 () => links.filter((link) => link.snapshot === null || link.snapshot.state === "open").length,316 () => links.filter((link) => link.snapshot === null || link.snapshot.state === "open").length,
269 [links],317 [links],
...@@ -304,6 +352,7 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread...@@ -304,6 +352,7 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread
304 line={line}352 line={line}
305 threadRef={threadRef}353 threadRef={threadRef}
306 onUnlink={handleUnlink}354 onUnlink={handleUnlink}
355 onSetWatching={supportsWatch ? handleSetWatching : null}
307 />356 />
308 ))}357 ))}
309 </div>358 </div>
docs/user/source-control.md+7
...@@ -181,6 +181,13 @@ closed reviews refresh periodically so reopening one on the host is detected. Me...@@ -181,6 +181,13 @@ closed reviews refresh periodically so reopening one on the host is detected. Me
181when requested. With **Auto-settle merged threads** enabled, a thread can settle after every linked181when requested. With **Auto-settle merged threads** enabled, a thread can settle after every linked
182review is terminal. An open or unsynced link keeps it active.182review is terminal. An open or unsynced link keeps it active.
183183
184Ask the agent to watch, monitor, or babysit a pull request and it calls `watch_pull_request`. While
185the thread is active, the server checks the pull request every minute and wakes the agent when a check
186fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict.
187Comments from your own account do not wake it. Watching ends when the pull request merges or closes,
188after 10 wakes in a row that bring only comments, or when the server cannot read the pull request for
18915 minutes. To start or stop it yourself, use the row menu in the **Linked pull requests** panel.
190
184Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked191Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked
185out from the matching organization and repository.192out from the matching organization and repository.
186193
packages/client-runtime/src/operations/commands.ts+14
...@@ -1027,6 +1027,20 @@ export const linkThreadPullRequest = Effect.fn("EnvironmentCommands.linkThreadPu...@@ -1027,6 +1027,20 @@ export const linkThreadPullRequest = Effect.fn("EnvironmentCommands.linkThreadPu
1027 });1027 });
1028 },1028 },
1029);1029);
1030export type WatchThreadPullRequestInput = Omit<
1031 Extract<OrchestrationV2Command, { type: "thread.pull-request.watch" }>,
1032 "type" | "commandId"
1033> &
1034 CommandMetadata;
1035export const watchThreadPullRequest = Effect.fn("EnvironmentCommands.watchThreadPullRequest")(
1036 function* (input: WatchThreadPullRequestInput) {
1037 return yield* dispatch({
1038 ...input,
1039 type: "thread.pull-request.watch",
1040 commandId: yield* allocateCommandId(input),
1041 });
1042 },
1043);
1030export const unlinkThreadPullRequest = Effect.fn("EnvironmentCommands.unlinkThreadPullRequest")(1044export const unlinkThreadPullRequest = Effect.fn("EnvironmentCommands.unlinkThreadPullRequest")(
1031 function* (input: UnlinkThreadPullRequestInput) {1045 function* (input: UnlinkThreadPullRequestInput) {
1032 return yield* dispatch({1046 return yield* dispatch({
packages/client-runtime/src/state/threadCommands.ts+9
...@@ -48,6 +48,7 @@ import {...@@ -48,6 +48,7 @@ import {
48 type UnarchiveThreadInput,48 type UnarchiveThreadInput,
49 type UnlinkThreadPullRequestInput,49 type UnlinkThreadPullRequestInput,
50 type UnpinThreadInput,50 type UnpinThreadInput,
51 type WatchThreadPullRequestInput,
51 type UnsettleThreadInput,52 type UnsettleThreadInput,
52 type UnsnoozeThreadInput,53 type UnsnoozeThreadInput,
53 type UpdateThreadMetadataInput,54 type UpdateThreadMetadataInput,
...@@ -86,6 +87,7 @@ import {...@@ -86,6 +87,7 @@ import {
86 unsnoozeThread,87 unsnoozeThread,
87 updateThreadMetadata,88 updateThreadMetadata,
88 visitThread,89 visitThread,
90 watchThreadPullRequest,
89} from "../operations/commands.ts";91} from "../operations/commands.ts";
90import type { EnvironmentRegistry } from "../connection/registry.ts";92import type { EnvironmentRegistry } from "../connection/registry.ts";
91import * as EnvironmentSupervisor from "../connection/supervisor.ts";93import * as EnvironmentSupervisor from "../connection/supervisor.ts";
...@@ -130,6 +132,7 @@ export type {...@@ -130,6 +132,7 @@ export type {
130 UnsnoozeThreadInput,132 UnsnoozeThreadInput,
131 UpdateThreadMetadataInput,133 UpdateThreadMetadataInput,
132 VisitThreadInput,134 VisitThreadInput,
135 WatchThreadPullRequestInput,
133} from "../operations/commands.ts";136} from "../operations/commands.ts";
134137
135export function createThreadEnvironmentAtoms<R, E>(138export function createThreadEnvironmentAtoms<R, E>(
...@@ -251,6 +254,12 @@ export function createThreadEnvironmentAtoms<R, E>(...@@ -251,6 +254,12 @@ export function createThreadEnvironmentAtoms<R, E>(
251 scheduler,254 scheduler,
252 concurrency,255 concurrency,
253 }),256 }),
257 watchPullRequest: createEnvironmentCommand(runtime, {
258 label: "environment-data:commands:thread:watch-pull-request",
259 execute: (input: WatchThreadPullRequestInput) => watchThreadPullRequest(input),
260 scheduler,
261 concurrency,
262 }),
254 setRuntimeMode: createEnvironmentCommand(runtime, {263 setRuntimeMode: createEnvironmentCommand(runtime, {
255 label: "environment-data:commands:thread:set-runtime-mode",264 label: "environment-data:commands:thread:set-runtime-mode",
256 execute: (input: SetThreadRuntimeModeInput) => setThreadRuntimeMode(input),265 execute: (input: SetThreadRuntimeModeInput) => setThreadRuntimeMode(input),
packages/client-runtime/src/t3ToolSummary.ts+10
...@@ -372,6 +372,16 @@ export function summarizeT3ToolCalls(...@@ -372,6 +372,16 @@ export function summarizeT3ToolCalls(
372 case "unlink-pr":372 case "unlink-pr":
373 label = phrase("Unlinked", "unlink", quantity(selected.length, "pull request"));373 label = phrase("Unlinked", "unlink", quantity(selected.length, "pull request"));
374 break;374 break;
375 case "watch-pr":
376 label = phrase("Watching", "watch", quantity(selected.length, "pull request"));
377 break;
378 case "unwatch-pr":
379 label = phrase(
380 "Stopped watching",
381 "stop watching",
382 quantity(selected.length, "pull request"),
383 );
384 break;
375 case "list-prs":385 case "list-prs":
376 label = phrase(386 label = phrase(
377 "Checked",387 "Checked",
packages/client-runtime/src/work-log/presentation.ts+9-1
...@@ -85,6 +85,8 @@ export type ToolGroupAction =...@@ -85,6 +85,8 @@ export type ToolGroupAction =
85 | "link-pr"85 | "link-pr"
86 | "unlink-pr"86 | "unlink-pr"
87 | "list-prs"87 | "list-prs"
88 | "watch-pr"
89 | "unwatch-pr"
88 | "read"90 | "read"
89 | "edit"91 | "edit"
90 | "command"92 | "command"
...@@ -156,7 +158,9 @@ function resolveT3McpToolPresentation(...@@ -156,7 +158,9 @@ function resolveT3McpToolPresentation(
156 const actionKind =158 const actionKind =
157 definition.summaryAction === "link-pr" ||159 definition.summaryAction === "link-pr" ||
158 definition.summaryAction === "unlink-pr" ||160 definition.summaryAction === "unlink-pr" ||
159 definition.summaryAction === "list-prs"161 definition.summaryAction === "list-prs" ||
162 definition.summaryAction === "watch-pr" ||
163 definition.summaryAction === "unwatch-pr"
160 ? definition.summaryAction164 ? definition.summaryAction
161 : undefined;165 : undefined;
162 const payload = asRecord(data);166 const payload = asRecord(data);
...@@ -539,6 +543,10 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {...@@ -539,6 +543,10 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
539 return `Linked ${count} ${count === 1 ? "pull request" : "pull requests"}`;543 return `Linked ${count} ${count === 1 ? "pull request" : "pull requests"}`;
540 case "unlink-pr":544 case "unlink-pr":
541 return `Unlinked ${count} ${count === 1 ? "pull request" : "pull requests"}`;545 return `Unlinked ${count} ${count === 1 ? "pull request" : "pull requests"}`;
546 case "watch-pr":
547 return `Watching ${count} ${count === 1 ? "pull request" : "pull requests"}`;
548 case "unwatch-pr":
549 return `Stopped watching ${count} ${count === 1 ? "pull request" : "pull requests"}`;
542 case "list-prs":550 case "list-prs":
543 return count === 1551 return count === 1
544 ? "Checked linked pull requests"552 ? "Checked linked pull requests"
packages/contracts/src/environment.ts+2
...@@ -162,6 +162,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -162,6 +162,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
162 shaping and validation when this is absent. */162 shaping and validation when this is absent. */
163 serverResolvedCommandContext: Schema.optionalKey(Schema.Boolean),163 serverResolvedCommandContext: Schema.optionalKey(Schema.Boolean),
164 threadPullRequests: Schema.optionalKey(Schema.Boolean),164 threadPullRequests: Schema.optionalKey(Schema.Boolean),
165 /** Server understands thread.pull-request.watch and wakes agents on pull request changes. */
166 threadPullRequestWatch: Schema.optionalKey(Schema.Boolean),
165 pullRequestStackActions: Schema.optionalKey(Schema.Boolean),167 pullRequestStackActions: Schema.optionalKey(Schema.Boolean),
166 /** The update path clients should offer for this server. Absent on168 /** The update path clients should offer for this server. Absent on
167 servers that must be relaunched manually (dev checkouts, Windows169 servers that must be relaunched manually (dev checkouts, Windows
packages/contracts/src/orchestrationV2.ts+34
...@@ -46,6 +46,7 @@ import {...@@ -46,6 +46,7 @@ import {
46 ThreadPullRequestLinkSource,46 ThreadPullRequestLinkSource,
47 ThreadPullRequestSnapshot,47 ThreadPullRequestSnapshot,
48 ThreadPullRequestStack,48 ThreadPullRequestStack,
49 ThreadPullRequestWatch,
49} from "./threadPullRequest.ts";50} from "./threadPullRequest.ts";
50import {51import {
51 ProviderApprovalDecision,52 ProviderApprovalDecision,
...@@ -2600,6 +2601,18 @@ export const OrchestrationV2Command = Schema.Union([...@@ -2600,6 +2601,18 @@ export const OrchestrationV2Command = Schema.Union([
2600 snapshot: ThreadPullRequestSnapshot,2601 snapshot: ThreadPullRequestSnapshot,
2601 stack: Schema.NullOr(ThreadPullRequestStack),2602 stack: Schema.NullOr(ThreadPullRequestStack),
2602 }),2603 }),
2604 /** Start or stop the server watching a linked pull request for this thread's agent. */
2605 Schema.Struct({
2606 type: Schema.Literal("thread.pull-request.watch"),
2607 commandId: CommandId,
2608 threadId: ThreadId,
2609 ...ThreadPullRequestKey.fields,
2610 watching: Schema.Boolean,
2611 /** Links the pull request first when starting a watch on one the thread has not linked. */
2612 link: Schema.optional(
2613 Schema.Struct({ url: TrimmedNonEmptyString, source: ThreadPullRequestLinkSource }),
2614 ),
2615 }),
2603 Schema.Struct({2616 Schema.Struct({
2604 type: Schema.Literal("thread.pull-request.sync"),2617 type: Schema.Literal("thread.pull-request.sync"),
2605 commandId: CommandId,2618 commandId: CommandId,
...@@ -2859,6 +2872,27 @@ export type OrchestrationV2Command = typeof OrchestrationV2Command.Type;...@@ -2859,6 +2872,27 @@ export type OrchestrationV2Command = typeof OrchestrationV2Command.Type;
2859 * send them.2872 * send them.
2860 */2873 */
2861const OrchestrationV2InternalCommand = Schema.Union([2874const OrchestrationV2InternalCommand = Schema.Union([
2875 /**
2876 * Records what a pull request watch saw, and wakes the agent in the same transaction when
2877 * `wake` is set. Rejected once the watch started at `startedAt` has ended, and a wake is
2878 * rejected on a settled or archived thread, so a read that raced either changes nothing.
2879 */
2880 Schema.Struct({
2881 type: Schema.Literal("thread.pull-request-watch.sync"),
2882 commandId: CommandId,
2883 threadId: ThreadId,
2884 ...ThreadPullRequestKey.fields,
2885 startedAt: IsoDateTime,
2886 /** The watch to record, or null to end it. */
2887 watch: Schema.NullOr(ThreadPullRequestWatch),
2888 wake: Schema.optional(
2889 Schema.Struct({
2890 messageId: MessageId,
2891 text: Schema.String,
2892 notification: OrchestrationV2Notification,
2893 }),
2894 ),
2895 }),
2862 /** Records that the provider rollback `requestId` failed for good. */2896 /** Records that the provider rollback `requestId` failed for good. */
2863 Schema.Struct({2897 Schema.Struct({
2864 type: Schema.Literal("checkpoint.rollback.fail"),2898 type: Schema.Literal("checkpoint.rollback.fail"),
packages/contracts/src/pullRequest.ts+4
...@@ -152,6 +152,8 @@ export const PullRequestCheck = Schema.Struct({...@@ -152,6 +152,8 @@ export const PullRequestCheck = Schema.Struct({
152 status: PullRequestCheckStatus,152 status: PullRequestCheckStatus,
153 description: Schema.NullOr(Schema.String),153 description: Schema.NullOr(Schema.String),
154 url: Schema.NullOr(Schema.String),154 url: Schema.NullOr(Schema.String),
155 /** The base branch requires this check to merge. Absent where the host does not say. */
156 required: Schema.optional(Schema.Boolean),
155});157});
156export type PullRequestCheck = typeof PullRequestCheck.Type;158export type PullRequestCheck = typeof PullRequestCheck.Type;
157159
...@@ -844,6 +846,8 @@ export const PullRequestDetail = Schema.Struct({...@@ -844,6 +846,8 @@ export const PullRequestDetail = Schema.Struct({
844 changedFiles: NonNegativeInt,846 changedFiles: NonNegativeInt,
845 headBranch: TrimmedNonEmptyString,847 headBranch: TrimmedNonEmptyString,
846 headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),848 headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
849 /** The head commit, where the host reports it with the detail. */
850 headSha: Schema.optional(TrimmedNonEmptyString),
847 baseBranch: TrimmedNonEmptyString,851 baseBranch: TrimmedNonEmptyString,
848 createdAt: IsoDateTime,852 createdAt: IsoDateTime,
849 updatedAt: IsoDateTime,853 updatedAt: IsoDateTime,
packages/contracts/src/threadPullRequest.ts+25
...@@ -92,6 +92,30 @@ export const ThreadPullRequestKey = Schema.Struct({...@@ -92,6 +92,30 @@ export const ThreadPullRequestKey = Schema.Struct({
92});92});
93export type ThreadPullRequestKey = typeof ThreadPullRequestKey.Type;93export type ThreadPullRequestKey = typeof ThreadPullRequestKey.Type;
9494
95/**
96 * Present while the server watches the pull request for its thread. The server wakes the
97 * thread's agent when checks finish on the head commit, someone else comments, or the branch
98 * starts to conflict. The other fields record what the agent was last told, so each change is
99 * reported once.
100 */
101export const ThreadPullRequestWatch = Schema.Struct({
102 startedAt: IsoDateTime,
103 /** Head commit at the last pass; null where the host does not report one. */
104 headSha: Schema.NullOr(TrimmedNonEmptyString),
105 /** Failed checks on that commit the agent was told about; a rerun that fails again is news. */
106 failedChecks: Schema.Array(TrimmedNonEmptyString),
107 /** The agent was told the required checks on that commit passed. */
108 passed: Schema.Boolean,
109 /** Remarks from others created up to this host time were reported. */
110 remarksThrough: IsoDateTime,
111 /** Remarks created exactly at `remarksThrough` that were reported, so a late one still counts. */
112 remarkIds: Schema.Array(TrimmedNonEmptyString),
113 conflicting: Schema.Boolean,
114 /** Comment-only wakes in a row. Watching stops at a limit, so bots cannot loop it. */
115 wakes: NonNegativeInt,
116});
117export type ThreadPullRequestWatch = typeof ThreadPullRequestWatch.Type;
118
95export const ThreadPullRequestLink = Schema.Struct({119export const ThreadPullRequestLink = Schema.Struct({
96 ...ThreadPullRequestKey.fields,120 ...ThreadPullRequestKey.fields,
97 url: TrimmedNonEmptyString,121 url: TrimmedNonEmptyString,
...@@ -99,5 +123,6 @@ export const ThreadPullRequestLink = Schema.Struct({...@@ -99,5 +123,6 @@ export const ThreadPullRequestLink = Schema.Struct({
99 linkedAt: IsoDateTime,123 linkedAt: IsoDateTime,
100 snapshot: Schema.NullOr(ThreadPullRequestSnapshot),124 snapshot: Schema.NullOr(ThreadPullRequestSnapshot),
101 stack: Schema.NullOr(ThreadPullRequestStack),125 stack: Schema.NullOr(ThreadPullRequestStack),
126 watch: Schema.optional(ThreadPullRequestWatch),
102});127});
103export type ThreadPullRequestLink = typeof ThreadPullRequestLink.Type;128export type ThreadPullRequestLink = typeof ThreadPullRequestLink.Type;
packages/shared/src/t3McpToolPresentation.ts+12
...@@ -55,6 +55,8 @@ export type T3McpToolSummaryAction =...@@ -55,6 +55,8 @@ export type T3McpToolSummaryAction =
55 | "link-pr"55 | "link-pr"
56 | "unlink-pr"56 | "unlink-pr"
57 | "list-prs"57 | "list-prs"
58 | "watch-pr"
59 | "unwatch-pr"
58 | "browser"60 | "browser"
59 | "device";61 | "device";
6062
...@@ -93,6 +95,16 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = {...@@ -93,6 +95,16 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = {
93 "list-prs",95 "list-prs",
94 "pull-request",96 "pull-request",
95 ),97 ),
98 watch_pull_request: tool(
99 ["Watch", "Watching", "Watching", "a pull request"],
100 "watch-pr",
101 "pull-request",
102 ),
103 unwatch_pull_request: tool(
104 ["Stop watching", "Stopping watching", "Stopped watching", "a pull request"],
105 "unwatch-pr",
106 "pull-request",
107 ),
96 orchestrator_capabilities: tool(108 orchestrator_capabilities: tool(
97 ["Get", "Getting", "Got", "orchestration capabilities"],109 ["Get", "Getting", "Got", "orchestration capabilities"],
98 "capabilities",110 "capabilities",