authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-05 15:01:41-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 15:01:41-07:00
log207f6a10b05cbdaee6fc650e915ba09dbe39d428
tree4bf21c7d45af9db02702c21af03df1eec8c2fc91
parentf5eb25071c042c4a729ddae9fd07bd81804907da
signature Signed by PGP key B5690EEEBB952194

fix(threads): threads watching a PR stay in Working instead of bouncing to the inbox (#16204)

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

15 files changed, 357 insertions(+), 10 deletions(-)

apps/server/src/mcp/toolkits/pullRequests/tools.ts+1-1
......@@ -289,7 +289,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", {
289289
290290const WatchPullRequestTool = Tool.make("watch_pull_request", {
291291 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 its thread settles, when T3 Code cannot read it for 15 minutes, when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch.",
292 "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every minute and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. While T3 Code watches, the thread stays in the user's Working list, not their inbox. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. Watching ends when the pull request merges or closes, when its thread settles, when T3 Code cannot read it for 15 minutes, when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch.",
293293 parameters: PullRequestTargetInput,
294294 success: WatchPullRequestResult,
295295 failure: PullRequestToolError,
apps/server/src/orchestration-v2/ProjectionStore.test.ts+123
......@@ -21,6 +21,7 @@ import {
2121 RunId,
2222 ThreadId,
2323 TurnItemId,
24 type ThreadPullRequestLink,
2425} from "@t3tools/contracts";
2526import * as DateTime from "effect/DateTime";
2627import * as Effect from "effect/Effect";
......@@ -4401,6 +4402,128 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => {
44014402 }),
44024403 );
44034404
4405 it.effect("a pull request watch keeps a finished thread working until it ends", () =>
4406 Effect.gen(function* () {
4407 const store = yield* ProjectionStore.ProjectionStoreV2;
4408 const threadId = ThreadId.make("thread:watched-pull-request");
4409 const runId = RunId.make("run:watched-pull-request");
4410 const at = DateTime.makeUnsafe("2026-10-05T12:00:00.000Z");
4411 const thread = {
4412 createdBy: "user" as const,
4413 creationSource: "web" as const,
4414 id: threadId,
4415 projectId: ProjectId.make("project:watched-pull-request"),
4416 title: "Babysit the PR",
4417 providerInstanceId,
4418 modelSelection,
4419 runtimeMode: "full-access" as const,
4420 interactionMode: "default" as const,
4421 branch: null,
4422 worktreePath: null,
4423 activeProviderThreadId: null,
4424 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
4425 forkedFrom: null,
4426 createdAt: at,
4427 updatedAt: at,
4428 archivedAt: null,
4429 settledOverride: null,
4430 settledAt: null,
4431 lastVisitedAt: null,
4432 deletedAt: null,
4433 };
4434 yield* store.apply({
4435 id: EventId.make("event:watched-pull-request:thread"),
4436 type: "thread.created",
4437 threadId,
4438 occurredAt: at,
4439 payload: thread,
4440 });
4441 yield* store.apply({
4442 id: EventId.make("event:watched-pull-request:run"),
4443 type: "run.created",
4444 threadId,
4445 runId,
4446 driver,
4447 providerInstanceId,
4448 occurredAt: at,
4449 payload: {
4450 id: runId,
4451 threadId,
4452 ordinal: 1,
4453 providerInstanceId,
4454 modelSelection,
4455 providerThreadId: null,
4456 userMessageId: MessageId.make("message:watched-pull-request"),
4457 rootNodeId: null,
4458 activeAttemptId: null,
4459 status: "completed",
4460 requestedAt: at,
4461 startedAt: at,
4462 completedAt: at,
4463 checkpointId: null,
4464 contextHandoffId: null,
4465 },
4466 });
4467 const link = {
4468 host: "github.com",
4469 repository: "pingdotgg/t3code",
4470 number: 7,
4471 url: "https://github.com/pingdotgg/t3code/pull/7",
4472 source: "agent" as const,
4473 linkedAt: DateTime.formatIso(at),
4474 snapshot: null,
4475 stack: null,
4476 };
4477 const syncPullRequests = (id: string, pullRequests: ReadonlyArray<ThreadPullRequestLink>) =>
4478 store.apply({
4479 id: EventId.make(`event:watched-pull-request:${id}`),
4480 type: "thread.pull-request-synced",
4481 threadId,
4482 occurredAt: at,
4483 payload: { ...thread, pullRequests },
4484 });
4485 const project = { title: "Project" };
4486 const environmentId = EnvironmentId.make("environment:watched-pull-request");
4487 const phase = Effect.gen(function* () {
4488 const shell = yield* store.getThreadShell(threadId);
4489 const listed = (yield* store.getShellSnapshot()).threads.find(
4490 (candidate) => candidate.id === threadId,
4491 );
4492 assert.deepEqual(listed?.pendingBackgroundTasks, shell?.pendingBackgroundTasks);
4493 return shell && projectThreadAwarenessV2({ environmentId, project, thread: shell })?.phase;
4494 });
4495
4496 yield* syncPullRequests("watched", [
4497 {
4498 ...link,
4499 watch: {
4500 startedAt: DateTime.formatIso(at),
4501 headSha: null,
4502 failedChecks: [],
4503 passed: false,
4504 passedChecks: [],
4505 remarksThrough: DateTime.formatIso(at),
4506 remarkIds: [],
4507 conflicting: false,
4508 wakes: 0,
4509 },
4510 },
4511 ]);
4512 assert.deepEqual((yield* store.getThreadShell(threadId))?.pendingBackgroundTasks, [
4513 {
4514 taskId: "pull-request-watch:github.com/pingdotgg/t3code#7",
4515 description: "Watching pull request #7",
4516 kind: "monitor",
4517 },
4518 ]);
4519 assert.equal(yield* phase, "running");
4520
4521 yield* syncPullRequests("unwatched", [link]);
4522 assert.deepEqual((yield* store.getThreadShell(threadId))?.pendingBackgroundTasks, []);
4523 assert.equal(yield* phase, "completed");
4524 }),
4525 );
4526
44044527 it.effect("dates a completion released by held background work at the release", () =>
44054528 Effect.gen(function* () {
44064529 const store = yield* ProjectionStore.ProjectionStoreV2;
apps/server/src/orchestration-v2/ProjectionStore.ts+3
......@@ -1346,6 +1346,7 @@ export function threadShellFromProjection(
13461346 turnItems: projection.turnItems,
13471347 activeProviderThreadId: projection.thread.activeProviderThreadId,
13481348 runs: projection.runs,
1349 pullRequests: projection.thread.pullRequests,
13491350 });
13501351 return {
13511352 createdBy: projection.thread.createdBy,
......@@ -5220,6 +5221,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
52205221 turnItems: pendingTurnItemsByThreadId.get(thread.id) ?? [],
52215222 activeProviderThreadId: thread.activeProviderThreadId,
52225223 hasActiveRun: false,
5224 pullRequests: thread.pullRequests,
52235225 }),
52245226 } satisfies ProjectionSettlementCandidate;
52255227 }),
......@@ -5338,6 +5340,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
53385340 turnItems: pendingTurnItemsByThreadId.get(thread.id) ?? [],
53395341 activeProviderThreadId: thread.activeProviderThreadId,
53405342 hasActiveRun: row.active_run_id !== null,
5343 pullRequests: thread.pullRequests,
53415344 }),
53425345 ];
53435346 return {
apps/server/src/orchestration-v2/PullRequestWatchReactor.ts+6-3
......@@ -193,10 +193,13 @@ export const make = Effect.gen(function* () {
193193 const check = Effect.fn("PullRequestWatchReactor.check")(function* (target: WatchTarget) {
194194 const { thread, link, watch } = target;
195195 const pullRequest = identityOf(link);
196 // A merged pull request cannot reopen, so its watch ends without a host read, even on a
197 // settled thread. A closed one can, so the host decides below.
196 // A merged pull request cannot reopen, so its watch ends without a host read. A closed one
197 // can, so the host decides below. Settling ends watches; one left from before that rule
198 // ends here, so the thread does not show as working while settled.
198199 if (link.snapshot?.state === "merged") return yield* record(target, null);
199 if (thread.settledOverride === "settled" || thread.settledAt !== null) return;
200 if (thread.settledOverride === "settled" || thread.settledAt !== null) {
201 return yield* record(target, null);
202 }
200203
201204 const reference = { projectId: thread.projectId, ...pullRequest };
202205 const read = yield* Effect.exit(
apps/server/src/orchestration-v2/pullRequestWatch.ts+1-1
......@@ -197,7 +197,7 @@ export function pullRequestWatchMessage(input: {
197197 "",
198198 exhausted
199199 ? `T3 Code stopped watching after ${PULL_REQUEST_WATCH_WAKE_LIMIT} comment-only updates in a row. Call watch_pull_request to watch it again.`
200 : "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.",
200 : "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. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.",
201201 ].join("\n");
202202 const failed = changes.some(
203203 (change) => change.kind === "checks-failed" || change.kind === "conflicting",
apps/server/src/provider/RuntimeInstructions.ts+1-1
......@@ -1,5 +1,5 @@
11const 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. 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.
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. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.
33</pull_request_linking>`;
44
55/**
apps/web/src/components/ChatView.tsx+1
......@@ -3561,6 +3561,7 @@ export default function ChatView(props: ChatViewProps) {
35613561 turnItems: serverProjection.turnItems,
35623562 activeProviderThreadId: serverProjection.thread.activeProviderThreadId,
35633563 runs: serverProjection.runs,
3564 pullRequests: serverProjection.thread.pullRequests,
35643565 }),
35653566 ];
35663567 }, [serverProjection]);
docs/user/source-control.md+4
......@@ -200,6 +200,10 @@ after 10 wakes in a row that bring only comments, when the server cannot read th
200200Unsettle the thread before starting a new watch. To start or stop it yourself, use the row menu in
201201the **Linked pull requests** panel.
202202
203A watched thread counts as working between wakes, so it stays in the **Working** section and does
204not auto-settle. Agents stop watching when they hand the work back to you, and the thread then
205returns to your inbox.
206
203207Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked
204208out from the matching organization and repository.
205209
packages/client-runtime/src/operations/commands.test.ts+54
......@@ -502,6 +502,60 @@ describe("V2 environment commands", () => {
502502 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
503503 );
504504
505 it.effect("Stop with no run left ends the thread's pull request watches", () =>
506 Effect.gen(function* () {
507 const link = {
508 host: "github.com",
509 repository: "pingdotgg/t3code",
510 url: "https://github.com/pingdotgg/t3code/pull/7",
511 source: "agent" as const,
512 linkedAt: "2026-10-05T00:00:00.000Z",
513 snapshot: null,
514 stack: null,
515 };
516 const watch = {
517 startedAt: "2026-10-05T00:00:00.000Z",
518 headSha: null,
519 failedChecks: [],
520 passed: false,
521 passedChecks: [],
522 remarksThrough: "2026-10-05T00:00:00.000Z",
523 remarkIds: [],
524 conflicting: false,
525 wakes: 0,
526 };
527 const projection: OrchestrationV2ThreadProjection = {
528 ...v2Projection,
529 thread: {
530 ...v2Projection.thread,
531 pullRequests: [
532 { ...link, number: 7, watch },
533 { ...link, number: 8 },
534 { ...link, number: 9, source: "stack-dismissed", watch },
535 ],
536 },
537 };
538 const commands: OrchestrationV2Command[] = [];
539 const supervisor = yield* makeSupervisor({ commands, projects: [], projection });
540
541 yield* interruptThreadTurn({ threadId: v2ThreadId }).pipe(
542 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
543 );
544
545 expect(commands).toEqual([
546 {
547 type: "thread.pull-request.watch",
548 commandId: expect.any(String),
549 threadId: v2ThreadId,
550 host: "github.com",
551 repository: "pingdotgg/t3code",
552 number: 7,
553 watching: false,
554 },
555 ]);
556 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
557 );
558
505559 it.effect.each([
506560 "waiting",
507561 "completed",
packages/client-runtime/src/operations/commands.ts+22
......@@ -28,6 +28,7 @@ import {
2828} from "@t3tools/contracts";
2929import { modelSelectionCommandType } from "@t3tools/shared/model";
3030import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
31import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
3132import * as Crypto from "effect/Crypto";
3233import * as Effect from "effect/Effect";
3334
......@@ -771,6 +772,11 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")(
771772 });
772773});
773774
775/**
776 * Stop for the thread's latest work: interrupts the active run, or the settled run whose
777 * background work still runs. With no run to stop, it ends the thread's pull request
778 * watches, the only background work that has no run.
779 */
774780export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* (
775781 input: InterruptThreadTurnInput,
776782) {
......@@ -798,6 +804,22 @@ export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThrea
798804 runId = latestRun?.id;
799805 }
800806 }
807 if (runId === undefined) {
808 let result = { sequence: 0 };
809 for (const link of visibleThreadPullRequests(projection.thread.pullRequests ?? [])) {
810 if (link.watch === undefined) continue;
811 result = yield* dispatch({
812 type: "thread.pull-request.watch",
813 commandId: yield* allocateCommandId(result.sequence === 0 ? input : {}),
814 threadId: input.threadId,
815 host: link.host,
816 repository: link.repository,
817 number: link.number,
818 watching: false,
819 });
820 }
821 return result;
822 }
801823 }
802824 if (runId === undefined) return { sequence: 0 };
803825 return yield* dispatch({
packages/client-runtime/src/state/entities.test.ts+22
......@@ -155,6 +155,28 @@ describe("V2 client presentation", () => {
155155 ]);
156156 });
157157
158 it("parks a thread that never ran at idle while a pull request watch holds it", () => {
159 const watch = { taskId: "pull-request-watch:github.com/acme/app#1", kind: "monitor" as const };
160 const watched = presentThreadShell(environmentId, {
161 ...v2ThreadShell,
162 latestRunId: null,
163 activeProviderThreadId: null,
164 activeRunId: null,
165 status: "idle",
166 pendingBackgroundTasks: [watch],
167 });
168 expect(watched.runtime).toMatchObject({ status: "idle", activeRunId: null });
169 expect(
170 presentThreadShell(environmentId, {
171 ...v2ThreadShell,
172 latestRunId: null,
173 activeProviderThreadId: null,
174 status: "idle",
175 pendingBackgroundTasks: [],
176 }).runtime,
177 ).toBeNull();
178 });
179
158180 it.each([
159181 { kinds: ["command"], expected: "completed" },
160182 { kinds: ["command", "subagent"], expected: "idle" },
packages/client-runtime/src/state/models.ts+4-1
......@@ -174,10 +174,13 @@ function terminalRunStatus(status: OrchestrationV2RunStatus): boolean {
174174// latestRun keeps the latest run's status for history presentation.
175175// A failed latest run outranks the roster, so the failure stays visible.
176176function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary | null {
177 if (thread.latestRunId === null && thread.activeProviderThreadId === null) return null;
178177 const parkAtIdle =
179178 backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) &&
180179 thread.status !== "failed";
180 // A pull request watch can hold a thread that never ran.
181 if (thread.latestRunId === null && thread.activeProviderThreadId === null && !parkAtIdle) {
182 return null;
183 }
181184 const status = parkAtIdle ? "idle" : (thread.activityRunStatus ?? thread.status);
182185 return {
183186 status,
packages/client-runtime/src/state/threadExecution.ts+11-3
......@@ -234,9 +234,6 @@ export function deriveThreadRuntime(
234234 const liveActivityRun = latestMatchingRun(projection, (run) =>
235235 ACTIVITY_RUN_STATUSES.has(run.status),
236236 );
237 if (latestRun === null && projection.thread.activeProviderThreadId === null) return null;
238 const activeRunId =
239 latestMatchingRun(projection, (run) => INTERRUPTIBLE_RUN_STATUSES.has(run.status))?.id ?? null;
240237 // Same rule as the shell runtime: only background work that holds the
241238 // completion parks the thread at idle; a dev server left running does not.
242239 const backgroundWorkHoldsRun = backgroundWorkHoldsCompletion(
......@@ -246,8 +243,19 @@ export function deriveThreadRuntime(
246243 turnItems: projection.turnItems,
247244 activeProviderThreadId: projection.thread.activeProviderThreadId,
248245 runs: projection.runs,
246 pullRequests: projection.thread.pullRequests,
249247 }),
250248 );
249 // A pull request watch can hold a thread that never ran.
250 if (
251 latestRun === null &&
252 projection.thread.activeProviderThreadId === null &&
253 !backgroundWorkHoldsRun
254 ) {
255 return null;
256 }
257 const activeRunId =
258 latestMatchingRun(projection, (run) => INTERRUPTIBLE_RUN_STATUSES.has(run.status))?.id ?? null;
251259 return {
252260 status: usageLimitedRun
253261 ? "failed"
packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts+69
......@@ -517,4 +517,73 @@ describe("derivePendingBackgroundWork kinds", () => {
517517 { taskId: "cmd", description: "npm test", kind: "command" },
518518 ]);
519519 });
520
521 describe("pull request watches", () => {
522 const watch = {
523 startedAt: "2026-10-05T00:00:00.000Z",
524 headSha: null,
525 failedChecks: [],
526 passed: false,
527 passedChecks: [],
528 remarksThrough: "2026-10-05T00:00:00.000Z",
529 remarkIds: [],
530 conflicting: false,
531 wakes: 0,
532 };
533 const link = (
534 number: number,
535 extra: { source?: "agent" | "stack-dismissed"; watched?: boolean },
536 ) => ({
537 host: "github.com",
538 repository: "acme/app",
539 number,
540 url: `https://github.com/acme/app/pull/${number}`,
541 source: extra.source ?? "agent",
542 ...(extra.watched === false ? {} : { watch }),
543 });
544 const pullRequests = [
545 link(1, {}),
546 link(2, { watched: false }),
547 link(3, { source: "stack-dismissed" }),
548 ];
549
550 it("keeps a settled run waiting on each visible watch, as a monitor", () => {
551 const tasks = derivePendingBackgroundWork({
552 latestRun: { id: "run-1" as never, ordinal: 1, status: "completed" },
553 providerThreads: [],
554 turnItems: [],
555 pullRequests,
556 });
557 expect(tasks).toEqual([
558 {
559 taskId: "pull-request-watch:github.com/acme/app#1",
560 description: "Watching pull request #1",
561 kind: "monitor",
562 },
563 ]);
564 expect(backgroundWorkHoldsCompletion(tasks)).toBe(true);
565 });
566
567 it("keeps a thread that never ran waiting on its watch", () => {
568 expect(
569 derivePendingBackgroundWork({
570 latestRun: null,
571 providerThreads: [],
572 turnItems: [],
573 pullRequests,
574 }).map((task) => task.taskId),
575 ).toEqual(["pull-request-watch:github.com/acme/app#1"]);
576 });
577
578 it("lists no watch while a run is active", () => {
579 expect(
580 derivePendingBackgroundWork({
581 latestRun: { id: "run-1" as never, ordinal: 1, status: "running" },
582 providerThreads: [],
583 turnItems: [],
584 pullRequests,
585 }),
586 ).toEqual([]);
587 });
588 });
520589});
packages/shared/src/orchestrationV2PendingBackgroundWork.ts+35
......@@ -4,9 +4,12 @@ import type {
44 OrchestrationV2Run,
55 OrchestrationV2TurnItem,
66 ThreadId,
7 ThreadPullRequestLink,
78} from "@t3tools/contracts";
89import { isOrchestrationV2WorkActive } from "@t3tools/contracts";
910
11import { threadPullRequestKeyOf } from "./threadPullRequests.ts";
12
1013const BACKGROUND_TURN_ITEM_TYPES = new Set<OrchestrationV2TurnItem["type"]>([
1114 "command_execution",
1215 "dynamic_tool",
......@@ -86,6 +89,11 @@ function backgroundWorkKindHoldsCompletion(kind: PendingBackgroundWorkTask["kind
8689
8790type PendingBackgroundWorkRun = Pick<OrchestrationV2Run, "id" | "ordinal" | "status">;
8891
92type PendingBackgroundWorkPullRequest = Pick<
93 ThreadPullRequestLink,
94 "host" | "repository" | "number" | "url" | "source" | "watch"
95>;
96
8997type PendingBackgroundWorkProviderThread = Pick<
9098 OrchestrationV2ProviderThread,
9199 "id" | "pendingBackgroundTasks"
......@@ -200,6 +208,10 @@ export function pendingBackgroundTurnItems<Item extends PendingBackgroundWorkTur
200208 * Sources:
201209 * - Provider-thread roster (Claude SDK background tasks)
202210 * - Active command_execution / dynamic_tool / subagent turn items
211 * - Pull request watches, as monitors: a watch wakes the agent, so the thread
212 * stays working between wakes instead of returning to the inbox. Callers
213 * that pick a run to interrupt leave `pullRequests` out; Stop ends watches
214 * on its own.
203215 *
204216 * Gated on latest root run settlement. Dedupes by native task ID. Excludes
205217 * the roster while any interruptible foreground run remains active. Excludes
......@@ -220,6 +232,7 @@ export function derivePendingBackgroundWork(input: {
220232 * pass projection runs so policy cannot drift.
221233 */
222234 readonly runs?: ReadonlyArray<PendingBackgroundWorkRun>;
235 readonly pullRequests?: ReadonlyArray<PendingBackgroundWorkPullRequest> | undefined;
223236}): ReadonlyArray<PendingBackgroundWorkTask> {
224237 const hasActiveRun =
225238 input.hasActiveRun ??
......@@ -230,6 +243,10 @@ export function derivePendingBackgroundWork(input: {
230243 if (hasActiveRun) {
231244 return [];
232245 }
246 // A thread that never ran waits on nothing else, but a watch started on it still wakes it.
247 if (input.latestRun == null) {
248 return pullRequestWatchTasks(input.pullRequests);
249 }
233250 if (!isLatestRunSettledForBackgroundWait(input.latestRun)) {
234251 return [];
235252 }
......@@ -264,5 +281,23 @@ export function derivePendingBackgroundWork(input: {
264281 byTaskId.set(taskId, pendingTaskFromTurnItem(taskId, item));
265282 }
266283
284 for (const task of pullRequestWatchTasks(input.pullRequests)) byTaskId.set(task.taskId, task);
285
267286 return Array.from(byTaskId.values());
268287}
288
289function pullRequestWatchTasks(
290 pullRequests: ReadonlyArray<PendingBackgroundWorkPullRequest> | undefined,
291): Array<PendingBackgroundWorkTask> {
292 return (pullRequests ?? []).flatMap((link) =>
293 link.watch === undefined || link.source === "stack-dismissed"
294 ? []
295 : [
296 {
297 taskId: `pull-request-watch:${threadPullRequestKeyOf(link)}`,
298 description: `Watching pull request #${link.number}`,
299 kind: "monitor" as const,
300 },
301 ],
302 );
303}