authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-10-05 19:01:19+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 09:01:19-07:00
log51f7f77f87318a5329d3bab15d513fda5654200b
tree81497483a7275ff9611ea8e2365264bd4e1136a5
parent68802a48f9a8f7f7db5ca4d605bdc818be62a804
signature Signed by PGP key B5690EEEBB952194

fix(threads): stop pull request watches when settling (#16095)


5 files changed, 119 insertions(+), 4 deletions(-)

apps/server/src/mcp/toolkits/pullRequests/tools.ts+1-1
...@@ -289,7 +289,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", {...@@ -289,7 +289,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", {
289289
290const WatchPullRequestTool = Tool.make("watch_pull_request", {290const WatchPullRequestTool = Tool.make("watch_pull_request", {
291 description:291 description:
292 "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every minute and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. 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.",292 "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every minute and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. Watching ends when the pull request merges or closes, when its thread settles, when T3 Code cannot read it for 15 minutes, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch.",
293 parameters: PullRequestTargetInput,293 parameters: PullRequestTargetInput,
294 success: WatchPullRequestResult,294 success: WatchPullRequestResult,
295 failure: PullRequestToolError,295 failure: PullRequestToolError,
apps/server/src/orchestration-v2/Orchestrator.ts+12
...@@ -2368,6 +2368,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -2368,6 +2368,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2368 threadId: command.threadId,2368 threadId: command.threadId,
2369 });2369 });
2370 }2370 }
2371 if (
2372 command.type === "thread.pull-request.watch" &&
2373 command.watching &&
2374 (thread.settledOverride === "settled" || thread.settledAt !== null)
2375 ) {
2376 return yield* new OrchestratorDispatchError({
2377 commandId: command.commandId,
2378 commandType: command.type,
2379 cause: `Thread ${command.threadId} is settled and cannot watch pull requests.`,
2380 });
2381 }
2371 if (2382 if (
2372 command.type === "thread.metadata.update" &&2383 command.type === "thread.metadata.update" &&
2373 command.expectedWorktreePath !== undefined &&2384 command.expectedWorktreePath !== undefined &&
...@@ -2685,6 +2696,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -2685,6 +2696,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2685 ...thread,2696 ...thread,
2686 settledOverride: "settled",2697 settledOverride: "settled",
2687 settledAt: alreadySettled ? thread.settledAt : (command.settledAt ?? now),2698 settledAt: alreadySettled ? thread.settledAt : (command.settledAt ?? now),
2699 pullRequests: thread.pullRequests?.map((link) => withPullRequestWatch(link, undefined)),
2688 unsettledAt: null,2700 unsettledAt: null,
2689 pinnedAt: null,2701 pinnedAt: null,
2690 pinOrderKey: null,2702 pinOrderKey: null,
apps/server/src/orchestration-v2/PullRequestWatchReactor.ts+2-2
...@@ -64,8 +64,8 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc...@@ -64,8 +64,8 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc
64/**64/**
65 * Wakes a thread's agent when a pull request it watches (`watch_pull_request`) needs a look:65 * Wakes a thread's agent when a pull request it watches (`watch_pull_request`) needs a look:
66 * checks finished on the head commit, someone else commented, or the branch started to66 * checks finished on the head commit, someone else commented, or the branch started to
67 * conflict. One pass a minute reads each watched pull request; settled threads wait until67 * conflict. One pass a minute reads each watched pull request; settling a thread ends its
68 * they are active again, and a merged or closed pull request ends its watch.68 * watches, and a merged or closed pull request ends its watch.
69 */69 */
70export class PullRequestWatchReactor extends Context.Service<70export class PullRequestWatchReactor extends Context.Service<
71 PullRequestWatchReactor,71 PullRequestWatchReactor,
apps/server/src/orchestration-v2/runtimeLayer.test.ts+102
...@@ -2250,6 +2250,108 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => {...@@ -2250,6 +2250,108 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => {
2250 }),2250 }),
2251 );2251 );
22522252
2253 it.effect.each(["manual", "automatic"])("settling ends every pull request watch: %s", (mode) =>
2254 Effect.gen(function* () {
2255 const orchestrator = yield* Orchestrator.OrchestratorV2;
2256 const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2;
2257 const threadId = ThreadId.make(`pr-watch-settle-${mode}`);
2258 yield* orchestrator.dispatch({
2259 type: "thread.create",
2260 createdBy: "user",
2261 creationSource: "web",
2262 commandId: CommandId.make(`pr-watch-settle-create-${mode}`),
2263 threadId,
2264 projectId: ProjectId.make("pr-watch-settle-project"),
2265 title: "Settle watched PRs",
2266 modelSelection,
2267 runtimeMode: "full-access",
2268 interactionMode: "default",
2269 branch: null,
2270 worktreePath: null,
2271 });
2272 const key = { host: "github.com", repository: "pingdotgg/t3code" };
2273 for (const number of [7, 8]) {
2274 yield* orchestrator.dispatch({
2275 type: "thread.pull-request.watch",
2276 commandId: CommandId.make(`pr-watch-settle-start-${mode}-${number}`),
2277 threadId,
2278 ...key,
2279 number,
2280 watching: true,
2281 link: { url: `https://github.com/pingdotgg/t3code/pull/${number}`, source: "agent" },
2282 });
2283 }
2284 const before = yield* orchestrator.getThreadShell(threadId);
2285 const started = before?.pullRequests?.[0]?.watch;
2286 assert.isDefined(started);
2287 if (started === undefined || before === null) return;
2288 yield* orchestrator.dispatch({
2289 ...(mode === "manual"
2290 ? { type: "thread.settle" as const }
2291 : { type: "thread.auto-settle" as const, snapshotAt: before.updatedAt }),
2292 commandId: CommandId.make(`pr-watch-settle-${mode}`),
2293 threadId,
2294 });
2295 const expected = before.pullRequests?.map(({ watch: _watch, ...link }) => link);
2296 assert.deepEqual((yield* orchestrator.getThreadShell(threadId))?.pullRequests, expected);
2297 assert.isTrue((yield* maintenance.rebuild).valid);
2298 assert.deepEqual((yield* orchestrator.getThreadShell(threadId))?.pullRequests, expected);
2299
2300 for (const wake of [
2301 undefined,
2302 {
2303 messageId: MessageId.make(`pr-watch-settle-late-wake-${mode}`),
2304 text: "Update",
2305 notification: {
2306 source: { kind: "monitor" as const },
2307 outcome: "updated" as const,
2308 summary: "#7",
2309 },
2310 },
2311 ]) {
2312 const late = yield* orchestrator
2313 .dispatch({
2314 type: "thread.pull-request-watch.sync",
2315 commandId: CommandId.make(`pr-watch-settle-late-${mode}-${wake !== undefined}`),
2316 threadId,
2317 ...key,
2318 number: 7,
2319 startedAt: started.startedAt,
2320 watch: { ...started, headSha: "late", wakes: 1 },
2321 ...(wake === undefined ? {} : { wake }),
2322 })
2323 .pipe(Effect.flip);
2324 assert.equal(late._tag, "OrchestratorDispatchError");
2325 }
2326 const restart = {
2327 type: "thread.pull-request.watch" as const,
2328 threadId,
2329 ...key,
2330 number: 7,
2331 watching: true,
2332 };
2333 const blocked = yield* orchestrator
2334 .dispatch({
2335 ...restart,
2336 commandId: CommandId.make(`pr-watch-settle-blocked-${mode}`),
2337 })
2338 .pipe(Effect.flip);
2339 assert.equal(blocked._tag, "OrchestratorDispatchError");
2340 assert.deepEqual((yield* orchestrator.getThreadRecords(threadId, ["messages"])).messages, []);
2341 yield* orchestrator.dispatch({
2342 type: "thread.unsettle",
2343 commandId: CommandId.make(`pr-watch-settle-unsettle-${mode}`),
2344 threadId,
2345 reason: "user",
2346 });
2347 yield* orchestrator.dispatch({
2348 ...restart,
2349 commandId: CommandId.make(`pr-watch-settle-restart-${mode}`),
2350 });
2351 assert.isDefined((yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch);
2352 }),
2353 );
2354
2253 it.effect("ends a watch it cannot read, and tells the agent", () =>2355 it.effect("ends a watch it cannot read, and tells the agent", () =>
2254 Effect.gen(function* () {2356 Effect.gen(function* () {
2255 const orchestrator = yield* Orchestrator.OrchestratorV2;2357 const orchestrator = yield* Orchestrator.OrchestratorV2;
docs/user/source-control.md+2-1
...@@ -190,7 +190,8 @@ the thread is active, the server checks the pull request every minute and wakes...@@ -190,7 +190,8 @@ the thread is active, the server checks the pull request every minute and wakes
190fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict.190fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict.
191Comments from your own account do not wake it. Watching ends when the pull request merges or closes,191Comments from your own account do not wake it. Watching ends when the pull request merges or closes,
192after 10 wakes in a row that bring only comments, or when the server cannot read the pull request for192after 10 wakes in a row that bring only comments, or when the server cannot read the pull request for
19315 minutes. To start or stop it yourself, use the row menu in the **Linked pull requests** panel.19315 minutes. Settling a thread also ends all its watches. Unsettle the thread before starting a new
194watch. To start or stop it yourself, use the row menu in the **Linked pull requests** panel.
194195
195Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked196Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked
196out from the matching organization and repository.197out from the matching organization and repository.