From 207f6a10b05cbdaee6fc650e915ba09dbe39d428 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 5 Oct 2026 15:01:41 -0700 Subject: [PATCH] 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) --- .../src/mcp/toolkits/pullRequests/tools.ts | 2 +- .../orchestration-v2/ProjectionStore.test.ts | 123 ++++++++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 3 + .../PullRequestWatchReactor.ts | 9 +- .../src/orchestration-v2/pullRequestWatch.ts | 2 +- .../src/provider/RuntimeInstructions.ts | 2 +- apps/web/src/components/ChatView.tsx | 1 + docs/user/source-control.md | 4 + .../src/operations/commands.test.ts | 54 ++++++++ .../client-runtime/src/operations/commands.ts | 22 ++++ .../client-runtime/src/state/entities.test.ts | 22 ++++ packages/client-runtime/src/state/models.ts | 5 +- .../src/state/threadExecution.ts | 14 +- ...chestrationV2PendingBackgroundWork.test.ts | 69 ++++++++++ .../orchestrationV2PendingBackgroundWork.ts | 35 +++++ 15 files changed, 357 insertions(+), 10 deletions(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 7ae9f27736361075619322fd84f3ab4626a84d66..1894014ebf2ae08d23326666c781b4afaa5d9d62 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -289,7 +289,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { const WatchPullRequestTool = Tool.make("watch_pull_request", { description: - "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.", + "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.", parameters: PullRequestTargetInput, success: WatchPullRequestResult, failure: PullRequestToolError, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 6c1189c6a1a22849f10f22145083f299c8ed6161..d931320055668afacedfc0885551040650329c51 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -21,6 +21,7 @@ import { RunId, ThreadId, TurnItemId, + type ThreadPullRequestLink, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -4401,6 +4402,128 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { }), ); + it.effect("a pull request watch keeps a finished thread working until it ends", () => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:watched-pull-request"); + const runId = RunId.make("run:watched-pull-request"); + const at = DateTime.makeUnsafe("2026-10-05T12:00:00.000Z"); + const thread = { + createdBy: "user" as const, + creationSource: "web" as const, + id: threadId, + projectId: ProjectId.make("project:watched-pull-request"), + title: "Babysit the PR", + providerInstanceId, + modelSelection, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: at, + updatedAt: at, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; + yield* store.apply({ + id: EventId.make("event:watched-pull-request:thread"), + type: "thread.created", + threadId, + occurredAt: at, + payload: thread, + }); + yield* store.apply({ + id: EventId.make("event:watched-pull-request:run"), + type: "run.created", + threadId, + runId, + driver, + providerInstanceId, + occurredAt: at, + payload: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId, + modelSelection, + providerThreadId: null, + userMessageId: MessageId.make("message:watched-pull-request"), + rootNodeId: null, + activeAttemptId: null, + status: "completed", + requestedAt: at, + startedAt: at, + completedAt: at, + checkpointId: null, + contextHandoffId: null, + }, + }); + const link = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + url: "https://github.com/pingdotgg/t3code/pull/7", + source: "agent" as const, + linkedAt: DateTime.formatIso(at), + snapshot: null, + stack: null, + }; + const syncPullRequests = (id: string, pullRequests: ReadonlyArray) => + store.apply({ + id: EventId.make(`event:watched-pull-request:${id}`), + type: "thread.pull-request-synced", + threadId, + occurredAt: at, + payload: { ...thread, pullRequests }, + }); + const project = { title: "Project" }; + const environmentId = EnvironmentId.make("environment:watched-pull-request"); + const phase = Effect.gen(function* () { + const shell = yield* store.getThreadShell(threadId); + const listed = (yield* store.getShellSnapshot()).threads.find( + (candidate) => candidate.id === threadId, + ); + assert.deepEqual(listed?.pendingBackgroundTasks, shell?.pendingBackgroundTasks); + return shell && projectThreadAwarenessV2({ environmentId, project, thread: shell })?.phase; + }); + + yield* syncPullRequests("watched", [ + { + ...link, + watch: { + startedAt: DateTime.formatIso(at), + headSha: null, + failedChecks: [], + passed: false, + passedChecks: [], + remarksThrough: DateTime.formatIso(at), + remarkIds: [], + conflicting: false, + wakes: 0, + }, + }, + ]); + assert.deepEqual((yield* store.getThreadShell(threadId))?.pendingBackgroundTasks, [ + { + taskId: "pull-request-watch:github.com/pingdotgg/t3code#7", + description: "Watching pull request #7", + kind: "monitor", + }, + ]); + assert.equal(yield* phase, "running"); + + yield* syncPullRequests("unwatched", [link]); + assert.deepEqual((yield* store.getThreadShell(threadId))?.pendingBackgroundTasks, []); + assert.equal(yield* phase, "completed"); + }), + ); + it.effect("dates a completion released by held background work at the release", () => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 1f9750fc9b50b563063671825138053ecdcb4de8..bec60cb4d79d9cd9ca7f1e525a7c8da22645adec 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1346,6 +1346,7 @@ export function threadShellFromProjection( turnItems: projection.turnItems, activeProviderThreadId: projection.thread.activeProviderThreadId, runs: projection.runs, + pullRequests: projection.thread.pullRequests, }); return { createdBy: projection.thread.createdBy, @@ -5220,6 +5221,7 @@ export const layer: Layer.Layer = turnItems: pendingTurnItemsByThreadId.get(thread.id) ?? [], activeProviderThreadId: thread.activeProviderThreadId, hasActiveRun: false, + pullRequests: thread.pullRequests, }), } satisfies ProjectionSettlementCandidate; }), @@ -5338,6 +5340,7 @@ export const layer: Layer.Layer = turnItems: pendingTurnItemsByThreadId.get(thread.id) ?? [], activeProviderThreadId: thread.activeProviderThreadId, hasActiveRun: row.active_run_id !== null, + pullRequests: thread.pullRequests, }), ]; return { diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 2d118fb0a493cc6048b28150de22c59651d8cb8e..5300742706380e2e0ddc5466c27b4913bee3113e 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -193,10 +193,13 @@ export const make = Effect.gen(function* () { const check = Effect.fn("PullRequestWatchReactor.check")(function* (target: WatchTarget) { const { thread, link, watch } = target; const pullRequest = identityOf(link); - // A merged pull request cannot reopen, so its watch ends without a host read, even on a - // settled thread. A closed one can, so the host decides below. + // A merged pull request cannot reopen, so its watch ends without a host read. A closed one + // can, so the host decides below. Settling ends watches; one left from before that rule + // ends here, so the thread does not show as working while settled. if (link.snapshot?.state === "merged") return yield* record(target, null); - if (thread.settledOverride === "settled" || thread.settledAt !== null) return; + if (thread.settledOverride === "settled" || thread.settledAt !== null) { + return yield* record(target, null); + } const reference = { projectId: thread.projectId, ...pullRequest }; const read = yield* Effect.exit( diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 4f2d8c82c6c1303a13078cf89b2737273c76c00b..57b93f7847d288d6ab949a3a83fcb46e3d37aab8 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -197,7 +197,7 @@ export function pullRequestWatchMessage(input: { "", exhausted ? `T3 Code stopped watching after ${PULL_REQUEST_WATCH_WAKE_LIMIT} comment-only updates in a row. Call watch_pull_request to watch it again.` - : "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.", + : "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.", ].join("\n"); const failed = changes.some( (change) => change.kind === "checks-failed" || change.kind === "conflicting", diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index 545e8be58443b10597bd2c928149369e9821a7a8..a7b1c5e4c2e05fecf6592cbbbb83928189bf8158 100644 --- a/apps/server/src/provider/RuntimeInstructions.ts +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -1,5 +1,5 @@ const PULL_REQUEST_LINKING_INSTRUCTIONS = ` -When 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 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. `; /** diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a0dde96d2c30d7bd4dc7fd095d1fffdd0ac276dc..58d7618b76b21280cb776745197f2ac5a97b8fab 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3561,6 +3561,7 @@ export default function ChatView(props: ChatViewProps) { turnItems: serverProjection.turnItems, activeProviderThreadId: serverProjection.thread.activeProviderThreadId, runs: serverProjection.runs, + pullRequests: serverProjection.thread.pullRequests, }), ]; }, [serverProjection]); diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 5d8fde969615a7a10e600593d18302981fe18819..b849fcdf2b89aa8e38972efcb5bac25ac745a789 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -200,6 +200,10 @@ after 10 wakes in a row that bring only comments, when the server cannot read th Unsettle the thread before starting a new watch. To start or stop it yourself, use the row menu in the **Linked pull requests** panel. +A watched thread counts as working between wakes, so it stays in the **Working** section and does +not auto-settle. Agents stop watching when they hand the work back to you, and the thread then +returns to your inbox. + Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked out from the matching organization and repository. diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index a9833f7499d75cf308ca8a66253291773cc99307..fc443ecfb49e9119fd49b0e649ac19e8a6f3e56b 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -502,6 +502,60 @@ describe("V2 environment commands", () => { }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + it.effect("Stop with no run left ends the thread's pull request watches", () => + Effect.gen(function* () { + const link = { + host: "github.com", + repository: "pingdotgg/t3code", + url: "https://github.com/pingdotgg/t3code/pull/7", + source: "agent" as const, + linkedAt: "2026-10-05T00:00:00.000Z", + snapshot: null, + stack: null, + }; + const watch = { + startedAt: "2026-10-05T00:00:00.000Z", + headSha: null, + failedChecks: [], + passed: false, + passedChecks: [], + remarksThrough: "2026-10-05T00:00:00.000Z", + remarkIds: [], + conflicting: false, + wakes: 0, + }; + const projection: OrchestrationV2ThreadProjection = { + ...v2Projection, + thread: { + ...v2Projection.thread, + pullRequests: [ + { ...link, number: 7, watch }, + { ...link, number: 8 }, + { ...link, number: 9, source: "stack-dismissed", watch }, + ], + }, + }; + const commands: OrchestrationV2Command[] = []; + const supervisor = yield* makeSupervisor({ commands, projects: [], projection }); + + yield* interruptThreadTurn({ threadId: v2ThreadId }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + + expect(commands).toEqual([ + { + type: "thread.pull-request.watch", + commandId: expect.any(String), + threadId: v2ThreadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + watching: false, + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + it.effect.each([ "waiting", "completed", diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index bedf552f9b203c14ff81aee4133677a46e08637f..84f4a2ad3cff83e85b5e5d5d938fac5a7abe9178 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -28,6 +28,7 @@ import { } from "@t3tools/contracts"; import { modelSelectionCommandType } from "@t3tools/shared/model"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; +import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -771,6 +772,11 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( }); }); +/** + * Stop for the thread's latest work: interrupts the active run, or the settled run whose + * background work still runs. With no run to stop, it ends the thread's pull request + * watches, the only background work that has no run. + */ export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* ( input: InterruptThreadTurnInput, ) { @@ -798,6 +804,22 @@ export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThrea runId = latestRun?.id; } } + if (runId === undefined) { + let result = { sequence: 0 }; + for (const link of visibleThreadPullRequests(projection.thread.pullRequests ?? [])) { + if (link.watch === undefined) continue; + result = yield* dispatch({ + type: "thread.pull-request.watch", + commandId: yield* allocateCommandId(result.sequence === 0 ? input : {}), + threadId: input.threadId, + host: link.host, + repository: link.repository, + number: link.number, + watching: false, + }); + } + return result; + } } if (runId === undefined) return { sequence: 0 }; return yield* dispatch({ diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index 1a2581609bcdd401a730c816b401ce4dd11b60ca..f72e65723260232554c20c0babd7bca5e503203d 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -155,6 +155,28 @@ describe("V2 client presentation", () => { ]); }); + it("parks a thread that never ran at idle while a pull request watch holds it", () => { + const watch = { taskId: "pull-request-watch:github.com/acme/app#1", kind: "monitor" as const }; + const watched = presentThreadShell(environmentId, { + ...v2ThreadShell, + latestRunId: null, + activeProviderThreadId: null, + activeRunId: null, + status: "idle", + pendingBackgroundTasks: [watch], + }); + expect(watched.runtime).toMatchObject({ status: "idle", activeRunId: null }); + expect( + presentThreadShell(environmentId, { + ...v2ThreadShell, + latestRunId: null, + activeProviderThreadId: null, + status: "idle", + pendingBackgroundTasks: [], + }).runtime, + ).toBeNull(); + }); + it.each([ { kinds: ["command"], expected: "completed" }, { kinds: ["command", "subagent"], expected: "idle" }, diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 55fa5a673616d25332b654040d94073f84383289..e9049239e7e7ee0277b06e40ec32340a2c449944 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -174,10 +174,13 @@ function terminalRunStatus(status: OrchestrationV2RunStatus): boolean { // latestRun keeps the latest run's status for history presentation. // A failed latest run outranks the roster, so the failure stays visible. function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary | null { - if (thread.latestRunId === null && thread.activeProviderThreadId === null) return null; const parkAtIdle = backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) && thread.status !== "failed"; + // A pull request watch can hold a thread that never ran. + if (thread.latestRunId === null && thread.activeProviderThreadId === null && !parkAtIdle) { + return null; + } const status = parkAtIdle ? "idle" : (thread.activityRunStatus ?? thread.status); return { status, diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 9139c1004e4e7a26ba6d38da49f483cf70ce5619..bef28ee725102fefea6e9641023e84cdfcde97d2 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -234,9 +234,6 @@ export function deriveThreadRuntime( const liveActivityRun = latestMatchingRun(projection, (run) => ACTIVITY_RUN_STATUSES.has(run.status), ); - if (latestRun === null && projection.thread.activeProviderThreadId === null) return null; - const activeRunId = - latestMatchingRun(projection, (run) => INTERRUPTIBLE_RUN_STATUSES.has(run.status))?.id ?? null; // Same rule as the shell runtime: only background work that holds the // completion parks the thread at idle; a dev server left running does not. const backgroundWorkHoldsRun = backgroundWorkHoldsCompletion( @@ -246,8 +243,19 @@ export function deriveThreadRuntime( turnItems: projection.turnItems, activeProviderThreadId: projection.thread.activeProviderThreadId, runs: projection.runs, + pullRequests: projection.thread.pullRequests, }), ); + // A pull request watch can hold a thread that never ran. + if ( + latestRun === null && + projection.thread.activeProviderThreadId === null && + !backgroundWorkHoldsRun + ) { + return null; + } + const activeRunId = + latestMatchingRun(projection, (run) => INTERRUPTIBLE_RUN_STATUSES.has(run.status))?.id ?? null; return { status: usageLimitedRun ? "failed" diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts index a766d5d30e43f7e7a487e49dc334b19f95b17544..013a49f0c12556f1c4c4e4ec856cf53c4aea22f1 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts @@ -517,4 +517,73 @@ describe("derivePendingBackgroundWork kinds", () => { { taskId: "cmd", description: "npm test", kind: "command" }, ]); }); + + describe("pull request watches", () => { + const watch = { + startedAt: "2026-10-05T00:00:00.000Z", + headSha: null, + failedChecks: [], + passed: false, + passedChecks: [], + remarksThrough: "2026-10-05T00:00:00.000Z", + remarkIds: [], + conflicting: false, + wakes: 0, + }; + const link = ( + number: number, + extra: { source?: "agent" | "stack-dismissed"; watched?: boolean }, + ) => ({ + host: "github.com", + repository: "acme/app", + number, + url: `https://github.com/acme/app/pull/${number}`, + source: extra.source ?? "agent", + ...(extra.watched === false ? {} : { watch }), + }); + const pullRequests = [ + link(1, {}), + link(2, { watched: false }), + link(3, { source: "stack-dismissed" }), + ]; + + it("keeps a settled run waiting on each visible watch, as a monitor", () => { + const tasks = derivePendingBackgroundWork({ + latestRun: { id: "run-1" as never, ordinal: 1, status: "completed" }, + providerThreads: [], + turnItems: [], + pullRequests, + }); + expect(tasks).toEqual([ + { + taskId: "pull-request-watch:github.com/acme/app#1", + description: "Watching pull request #1", + kind: "monitor", + }, + ]); + expect(backgroundWorkHoldsCompletion(tasks)).toBe(true); + }); + + it("keeps a thread that never ran waiting on its watch", () => { + expect( + derivePendingBackgroundWork({ + latestRun: null, + providerThreads: [], + turnItems: [], + pullRequests, + }).map((task) => task.taskId), + ).toEqual(["pull-request-watch:github.com/acme/app#1"]); + }); + + it("lists no watch while a run is active", () => { + expect( + derivePendingBackgroundWork({ + latestRun: { id: "run-1" as never, ordinal: 1, status: "running" }, + providerThreads: [], + turnItems: [], + pullRequests, + }), + ).toEqual([]); + }); + }); }); diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index 074b5feca534460ee706bddbf05434591d266d5a..5a17bad321ed26af35c629b2e5f68bbb99f00758 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -4,9 +4,12 @@ import type { OrchestrationV2Run, OrchestrationV2TurnItem, ThreadId, + ThreadPullRequestLink, } from "@t3tools/contracts"; import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; +import { threadPullRequestKeyOf } from "./threadPullRequests.ts"; + const BACKGROUND_TURN_ITEM_TYPES = new Set([ "command_execution", "dynamic_tool", @@ -86,6 +89,11 @@ function backgroundWorkKindHoldsCompletion(kind: PendingBackgroundWorkTask["kind type PendingBackgroundWorkRun = Pick; +type PendingBackgroundWorkPullRequest = Pick< + ThreadPullRequestLink, + "host" | "repository" | "number" | "url" | "source" | "watch" +>; + type PendingBackgroundWorkProviderThread = Pick< OrchestrationV2ProviderThread, "id" | "pendingBackgroundTasks" @@ -200,6 +208,10 @@ export function pendingBackgroundTurnItems; + readonly pullRequests?: ReadonlyArray | undefined; }): ReadonlyArray { const hasActiveRun = input.hasActiveRun ?? @@ -230,6 +243,10 @@ export function derivePendingBackgroundWork(input: { if (hasActiveRun) { return []; } + // A thread that never ran waits on nothing else, but a watch started on it still wakes it. + if (input.latestRun == null) { + return pullRequestWatchTasks(input.pullRequests); + } if (!isLatestRunSettledForBackgroundWait(input.latestRun)) { return []; } @@ -264,5 +281,23 @@ export function derivePendingBackgroundWork(input: { byTaskId.set(taskId, pendingTaskFromTurnItem(taskId, item)); } + for (const task of pullRequestWatchTasks(input.pullRequests)) byTaskId.set(task.taskId, task); + return Array.from(byTaskId.values()); } + +function pullRequestWatchTasks( + pullRequests: ReadonlyArray | undefined, +): Array { + return (pullRequests ?? []).flatMap((link) => + link.watch === undefined || link.source === "stack-dismissed" + ? [] + : [ + { + taskId: `pull-request-watch:${threadPullRequestKeyOf(link)}`, + description: `Watching pull request #${link.number}`, + kind: "monitor" as const, + }, + ], + ); +} -- 2.54.0