From b16ae8d45b25c6a078940a240586179578f7c7dd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 06:04:34 +0000 Subject: [PATCH 1/5] test(shared): a watch whose checks all finished still holds its thread Repro for #34770. Fails on main: every active PR watch is an awaited task. AI-assisted; verified with the tests and tools listed above. --- .../orchestrationV2PendingBackgroundWork.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts index 1b5331c0d1be..31a2e47b17f6 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts @@ -420,6 +420,21 @@ describe("derivePendingBackgroundWork kinds", () => { expect(backgroundWorkHoldsCompletion(tasks)).toBe(true); }); + it("stops holding a settled run once every check on the watched head has finished", () => { + const tasks = derivePendingBackgroundWork({ + latestRun: { id: "run-1" as never, ordinal: 1, status: "completed" }, + providerThreads: [], + turnItems: [], + pullRequests: [ + { + ...link(1, {}), + watch: { ...watch, headSha: "abc123", passed: true, passedChecks: ["ci", "lint"] }, + }, + ], + }); + expect(backgroundWorkHoldsCompletion(tasks)).toBe(false); + }); + it("keeps a thread that never ran waiting on its watch", () => { expect( derivePendingBackgroundWork({ From fd1bf1be6b6da1855d224976060ff6da5772dc6e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 06:20:08 +0000 Subject: [PATCH 2/5] fix(shared): a watched PR whose checks passed stops holding its thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every active, unmerged PR watch was an awaited task, so a thread whose checks had all passed stayed "Waiting · Watching pull request" until the agent called unwatch_pull_request. A watch whose head passed with no checks still owed now stays listed but is not awaited, and no longer counts as a per-line monitor for a wake run's completion, so the thread returns to the inbox. Auto-settle still skips any watched thread, since settling ends the watch. Fixes pingdotgg/t3code#34770 AI-assisted; verified with the tests and tools listed above. --- .../ThreadSettlementService.test.ts | 17 ++++++++++++++++ .../ThreadSettlementService.ts | 3 +++ docs/user/source-control.md | 7 ++++--- ...chestrationV2PendingBackgroundWork.test.ts | 7 +++++++ .../orchestrationV2PendingBackgroundWork.ts | 20 ++++++++++++++++--- 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index cca6ee08e711..fcb1ab004217 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -149,6 +149,23 @@ describe("isAutoSettlementCandidate", () => { ).toBe(true); }); + it("never settles a thread whose pull request watch has stopped holding it", () => { + expect( + ThreadSettlementService.isAutoSettlementCandidate( + shell({ + pendingBackgroundTasks: [ + { + taskId: "pull-request-watch:github.com/acme/app#1", + kind: "monitor", + description: "Watching pull request #1", + }, + ], + }), + NOW_MS, + ), + ).toBe(false); + }); + it("never settles, or keeps snoozed, a thread whose guest is waiting on the owner", () => { expect( ThreadSettlementService.isAutoSettlementCandidate( diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 05a13cd8a399..1b1782e0ca2c 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -1,5 +1,6 @@ import { backgroundWorkHoldsCompletion, + isPullRequestWatchTask, turnItemUpdateCanEndBackgroundWork, } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; @@ -155,6 +156,8 @@ export function isAutoSettlementCandidate( if ((thread.pendingGuestMessageCount ?? 0) > 0) return false; if (thread.activityRunStatus != null) return false; if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) return false; + // Settling ends a watch, which would drop a reviewer's later comments. + if ((thread.pendingBackgroundTasks ?? []).some(isPullRequestWatchTask)) return false; if (threadHasQueuedTurnStart(thread, nowMs)) return false; // Interrupted runs must keep the same snooze protection the sidebar shows. return !isSnoozed(thread, nowMs); diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 40ae9efc959a..e90161b7851e 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -337,9 +337,10 @@ watch already reported; unlinking the pull request clears that. Subagents cannot it yourself, use the row menu in the **Linked pull requests** panel. In the thread details card, a watched pull request shows an eye; click it to stop watching. -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. +A watched thread counts as working between wakes while its checks run, so it stays in the +**Working** section. Once every check has passed, the thread returns to your inbox even if the +agent did not stop watching, and the watch keeps waking it on new comments, failed checks or conflicts. A +watched thread does not auto-settle. 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/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts index 31a2e47b17f6..048ffee10a1b 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts @@ -3,6 +3,7 @@ import { ProviderInstanceId, ProviderThreadId } from "@t3tools/contracts"; import { backgroundWorkHoldsCompletion, derivePendingBackgroundWork, + monitorHoldsWakeCompletion, } from "./orchestrationV2PendingBackgroundWork.ts"; describe("backgroundWorkHoldsCompletion", () => { @@ -433,6 +434,12 @@ describe("derivePendingBackgroundWork kinds", () => { ], }); expect(backgroundWorkHoldsCompletion(tasks)).toBe(false); + expect( + monitorHoldsWakeCompletion({ + latestRunBackgroundWake: true, + pendingBackgroundTasks: tasks, + }), + ).toBe(false); }); it("keeps a thread that never ran waiting on its watch", () => { diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index dfdf9a24ace1..c60a111a29c1 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -108,13 +108,23 @@ function backgroundWorkKindHoldsCompletion(kind: PendingBackgroundWorkTask["kind } } +const PULL_REQUEST_WATCH_TASK_PREFIX = "pull-request-watch:"; + +/** Listed while the watch is on, even after its checks finish and it stops holding the thread. */ +export function isPullRequestWatchTask(task: Pick): boolean { + return task.taskId.startsWith(PULL_REQUEST_WATCH_TASK_PREFIX); +} + /** A monitor wakes the agent once per line, so a progress wake run's completion waits for the monitor to end. */ export function monitorHoldsWakeCompletion( thread: Pick, ): boolean { return ( thread.latestRunBackgroundWake === true && - (thread.pendingBackgroundTasks ?? []).some((task) => task.kind === "monitor") + (thread.pendingBackgroundTasks ?? []).some( + // A pull request watch wakes on news rather than per line, and holds only while awaited. + (task) => task.kind === "monitor" && !isPullRequestWatchTask(task), + ) ); } @@ -313,10 +323,14 @@ function pullRequestWatchTasks( { // Links store their normalized identity, and this id only has to be unique within the // thread, so it reads those fields instead of parsing the URL. - taskId: `pull-request-watch:${link.host.toLowerCase()}/${link.repository.toLowerCase()}#${link.number}`, + taskId: `${PULL_REQUEST_WATCH_TASK_PREFIX}${link.host.toLowerCase()}/${link.repository.toLowerCase()}#${link.number}`, description: `Watching pull request #${link.number}`, kind: "monitor" as const, - awaited: true as const, + // Once every check on the head has passed, a person moves the PR next, so the watch + // stays listed but no longer holds the thread. A push resets `passed` at the next read. + ...(link.watch.passed && (link.watch.owedChecks ?? []).length === 0 + ? {} + : { awaited: true as const }), }, ], ); From f5ca7c440d7fbbaf43296075410bc037c7af4c2e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 06:30:13 +0000 Subject: [PATCH 3/5] fix(server): a watched PR holds its thread while any check runs A watch stays passed when a non-required check reruns on the same head, so releasing on passed alone let the thread return to the inbox while a review bot was still running. The watch now records whether a check was pending at its last read, and releases the thread only when none was. AI-assisted; verified with the tests and tools listed above. --- .../PullRequestWatchReactor.ts | 1 + .../orchestration-v2/pullRequestWatch.test.ts | 7 ++++++- .../src/orchestration-v2/pullRequestWatch.ts | 3 +++ packages/contracts/src/threadPullRequest.ts | 2 ++ ...chestrationV2PendingBackgroundWork.test.ts | 20 ++++++++++++++++++- .../orchestrationV2PendingBackgroundWork.ts | 4 ++-- 6 files changed, 33 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 6fdea57bf9e1..dd36834c97cb 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -182,6 +182,7 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc (left.owedChecks ?? []).join("\n") === (right.owedChecks ?? []).join("\n") && (left.awaitingApproval ?? []).join("\n") === (right.awaitingApproval ?? []).join("\n") && (left.approvalAnnounced ?? false) === (right.approvalAnnounced ?? false) && + left.checksPending === right.checksPending && left.remarksThrough === right.remarksThrough && left.remarkIds.join("\n") === right.remarkIds.join("\n") && left.conflicting === right.conflicting && diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index dece3e344983..f36d6ed9d6e6 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -309,8 +309,13 @@ describe("evaluatePullRequestWatch", () => { const botPending = detail({ checks: [...done.checks, check("bot", "pending")] }); const churn = evaluatePullRequestWatch(final.next, botPending, noRemarks); assert.deepEqual(churn.changes, []); + // It still holds the thread while it runs, though the watch stays passed. + assert.equal(final.next.checksPending, false); + assert.equal(churn.next.checksPending, true); const botDone = detail({ checks: [...done.checks, check("bot", "success")] }); - assert.deepEqual(evaluatePullRequestWatch(churn.next, botDone, noRemarks).changes, []); + const settled = evaluatePullRequestWatch(churn.next, botDone, noRemarks); + assert.deepEqual(settled.changes, []); + assert.equal(settled.next.checksPending, false); // A pending check that turns required on the next read still finishes with one wake. const promoted = detail({ diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 046c48e1858b..64706a2e3e2e 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -56,12 +56,14 @@ export function evaluatePullRequestWatch( let owedChecks = headMoved ? [] : (watch.owedChecks ?? []); let awaitingApproval = headMoved ? [] : (watch.awaitingApproval ?? []); let approvalAnnounced = headMoved ? false : (watch.approvalAnnounced ?? false); + let checksPending = headMoved ? undefined : watch.checksPending; // An unavailable approval read cannot establish success or that no checks exist. const unread = detail.checks.some((check) => check.placeholder === true); const checks = detail.checks.filter((check) => check.placeholder !== true); const failed = checks.filter(isFailedCheck); const awaiting = checks.filter((check) => check.status === "action-required"); if (checks.length > 0) { + checksPending = checks.some((check) => check.status === "pending"); const newlyFailed = failed.filter((check) => !failedChecks.includes(check.name)); if (newlyFailed.length > 0) changes.push({ kind: "checks-failed", failed: newlyFailed }); if (!approvalAnnounced && awaiting.length > 0) { @@ -200,6 +202,7 @@ export function evaluatePullRequestWatch( owedChecks, awaitingApproval, approvalAnnounced, + ...(checksPending !== undefined && { checksPending }), remarksThrough, remarkIds, conflicting, diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index bd0a65a3c0bc..4f9b4cf62571 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -91,6 +91,8 @@ export const ThreadPullRequestWatch = Schema.Struct({ owedChecks: Schema.optional(Schema.Array(TrimmedNonEmptyString)), /** Approval holds keyed by workflow run URL, or check name when no run URL is known. */ awaitingApproval: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + /** A check on the head was still running at the last read; absent until the head is read. */ + checksPending: Schema.optional(Schema.Boolean), /** The agent was told checks await approval on this head, so later ones stay quiet. */ approvalAnnounced: Schema.optional(Schema.Boolean), remarksThrough: IsoDateTime, diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts index 048ffee10a1b..fd4b5cb05eda 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts @@ -429,7 +429,13 @@ describe("derivePendingBackgroundWork kinds", () => { pullRequests: [ { ...link(1, {}), - watch: { ...watch, headSha: "abc123", passed: true, passedChecks: ["ci", "lint"] }, + watch: { + ...watch, + headSha: "abc123", + passed: true, + passedChecks: ["ci", "lint"], + checksPending: false, + }, }, ], }); @@ -440,6 +446,18 @@ describe("derivePendingBackgroundWork kinds", () => { pendingBackgroundTasks: tasks, }), ).toBe(false); + const rerun = derivePendingBackgroundWork({ + latestRun: { id: "run-1" as never, ordinal: 1, status: "completed" }, + providerThreads: [], + turnItems: [], + pullRequests: [ + { + ...link(1, {}), + watch: { ...watch, headSha: "abc123", passed: true, checksPending: true }, + }, + ], + }); + expect(backgroundWorkHoldsCompletion(rerun)).toBe(true); }); it("keeps a thread that never ran waiting on its watch", () => { diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index c60a111a29c1..fa8f9a781a8c 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -327,8 +327,8 @@ function pullRequestWatchTasks( description: `Watching pull request #${link.number}`, kind: "monitor" as const, // Once every check on the head has passed, a person moves the PR next, so the watch - // stays listed but no longer holds the thread. A push resets `passed` at the next read. - ...(link.watch.passed && (link.watch.owedChecks ?? []).length === 0 + // stays listed but no longer holds the thread. A push resets both at the next read. + ...(link.watch.passed && link.watch.checksPending === false ? {} : { awaited: true as const }), }, From 98672ae79fc6d166eae375e7e43ae0c64f1fc17d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 06:41:30 +0000 Subject: [PATCH 4/5] fix(server): a push keeps a watched thread waiting until its new head is read The saved finished-checks answer described the old head until the next watch read, so a fix-and-push turn could release its thread while the new head's checks ran. When a shell command in a watched thread ends, the watch reactor now forgets that answer, under the same permit as its reads so an in-flight read cannot restore it, and the next sweep reads the head again. AI-assisted; verified with the tests and tools listed above. --- .../PullRequestWatchReactor.ts | 43 +++++++++++++++++-- .../src/orchestration-v2/runtimeLayer.test.ts | 12 ++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index dd36834c97cb..e1dfbd08238b 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -1,5 +1,6 @@ import { CommandId, + isOrchestrationV2WorkActive, MessageId, type OrchestrationV2Notification, type PullRequestActivity, @@ -7,6 +8,7 @@ import { type PullRequestRef, type PullRequestThreadCommentsResult, type ThreadPullRequestLink, + type ThreadId, type ThreadPullRequestWatch, } from "@t3tools/contracts"; import { @@ -149,10 +151,12 @@ function planRead( return { detail: true, activity: true }; } // Nothing on the host changes when the no-checks deadline passes, so it alone prompts a read. + // A passed watch whose checks answer a command cleared reads its head again, since it may have moved. const checksDue = group.targets.some( ({ watch }) => - watch.checksAwaitedSince != null && - now - Date.parse(watch.checksAwaitedSince) >= PULL_REQUEST_WATCH_NO_CHECKS_MS, + (watch.passed && watch.checksPending === undefined) || + (watch.checksAwaitedSince != null && + now - Date.parse(watch.checksAwaitedSince) >= PULL_REQUEST_WATCH_NO_CHECKS_MS), ); if (fingerprint === null) { const read = @@ -196,6 +200,8 @@ export class PullRequestWatchReactor extends Context.Service< { readonly start: () => Effect.Effect; readonly sweep: Effect.Effect; + /** Forgets a thread's finished-checks answers, since its shell command may have pushed. */ + readonly reopenChecks: (threadId: ThreadId) => Effect.Effect; } >()("t3/orchestration-v2/PullRequestWatchReactor") {} @@ -705,6 +711,28 @@ export const make = Effect.gen(function* () { Effect.withSpan("PullRequestWatchReactor.sweep"), ); + // Under the read permit, so a read already in flight cannot save its old-head answer after this. + const reopenChecks = Effect.fn("PullRequestWatchReactor.reopenChecks")( + function* (threadId: ThreadId) { + const threads = yield* projections.getThreadsWithPullRequests(threadId); + const finished = threads.flatMap((thread) => + visibleThreadPullRequests(thread.pullRequests ?? []).flatMap((link) => + link.watch?.checksPending === false ? [{ thread, link, watch: link.watch }] : [], + ), + ); + yield* Effect.forEach( + finished, + (target) => { + const { checksPending: _, ...watch } = target.watch; + return record(target, watch); + }, + { discard: true }, + ); + }, + passes.withPermit, + Effect.catchCause(logFailure("pull request watch checks reopen failed", {})), + ); + const pullRequestOf = (group: WatchGroup) => threadPullRequestKeyOf(group.targets[0]!.link); const readOnChange = (pullRequest: string) => @@ -737,6 +765,15 @@ export const make = Effect.gen(function* () { ); } }); + yield* forkParked( + Stream.runForEach(engine.streamDomainEvents, (event) => + event.type === "turn-item.updated" && + event.payload.type === "command_execution" && + !isOrchestrationV2WorkActive(event.payload.status) + ? reopenChecks(event.threadId) + : Effect.void, + ).pipe(Effect.catchCause(logFailure("pull request watch command stream failed", {}))), + ); yield* forkParked( sweep.pipe( Effect.andThen(follow), @@ -746,7 +783,7 @@ export const make = Effect.gen(function* () { ); }); - return { start, sweep } satisfies PullRequestWatchReactor["Service"]; + return { start, sweep, reopenChecks } satisfies PullRequestWatchReactor["Service"]; }); export const layer = Layer.effect(PullRequestWatchReactor, make); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 78c9b5996dcd..cdf9178f90f1 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3925,6 +3925,18 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { yield* reactor.sweep; assert.deepEqual(yield* summaries, ["#12: checks passed"]); + // A shell command may have pushed, so the answer waits for the next read of the head. + const checksPending = Effect.map( + orchestrator.getThreadShell(threadId), + (thread) => thread?.pullRequests?.[0]?.watch?.checksPending, + ); + assert.equal(yield* checksPending, false); + yield* reactor.reopenChecks(threadId); + assert.isUndefined(yield* checksPending); + yield* reactor.sweep; + assert.equal(yield* checksPending, false); + assert.deepEqual(yield* summaries, ["#12: checks passed"]); + const restarted = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); yield* restarted.sweep; assert.deepEqual(yield* summaries, ["#12: checks passed"]); From 6f8e2572fa009f351e7254910eb1585dfa1f1950 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:35:14 +0000 Subject: [PATCH 5/5] fix(client-runtime): a released PR watch still reads as waiting Once a watch stopped holding its thread, the composer and the background work details called it "Running" although nothing ran: the watch only waits on its host. The label now says "Waiting" for PR watches whether or not they hold the thread. AI-assisted; verified with the tests and tools listed above. --- .../web/src/components/chat/BackgroundWorkDetails.tsx | 3 ++- .../client-runtime/src/state/threadExecution.test.ts | 11 +++++++++++ packages/client-runtime/src/state/threadExecution.ts | 4 +++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/BackgroundWorkDetails.tsx b/apps/web/src/components/chat/BackgroundWorkDetails.tsx index 86df01be78f5..27f8d39755be 100644 --- a/apps/web/src/components/chat/BackgroundWorkDetails.tsx +++ b/apps/web/src/components/chat/BackgroundWorkDetails.tsx @@ -4,6 +4,7 @@ import type { ThreadId, } from "@t3tools/contracts"; import type { ThreadStructure } from "@t3tools/client-runtime/state/shell"; +import { isPullRequestWatchTask } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { useState } from "react"; import { InlineButton } from "../ui/button"; @@ -59,7 +60,7 @@ export function BackgroundWorkDetails(props: {

{title}

- {task.awaited ? "Waiting" : "Running"} + {task.awaited || isPullRequestWatchTask(task) ? "Waiting" : "Running"}
{command ? ( diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index fd9f1c4de8cf..98c2391b6456 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -645,4 +645,15 @@ describe("presentPendingBackgroundWork", () => { ]); expect(presentation?.items[0]?.label).toBe("mv a.ts b.ts\ncat > b.ts <<'EOF'\nexport {};\nEOF"); }); + + it("says a pull request watch is waiting after it stops holding the thread", () => { + const presentation = presentPendingBackgroundWork([ + { + taskId: "pull-request-watch:github.com/acme/app#1", + kind: "monitor", + description: "Watching pull request #1", + }, + ]); + expect(presentation?.title).toBe("Waiting · Watching pull request #1"); + }); }); diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 44600ffa259e..d11aba16e322 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -25,6 +25,7 @@ import { } from "@t3tools/contracts"; import { backgroundWorkHoldsCompletion, + isPullRequestWatchTask, derivePendingBackgroundWork, } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { @@ -569,7 +570,8 @@ export function presentPendingBackgroundWork( tasks: ReadonlyArray, ): PendingBackgroundWorkPresentation | null { if (tasks.length === 0) return null; - const waiting = backgroundWorkHoldsCompletion(tasks); + // A watch waits on its host even once it stops holding the thread; nothing of it runs. + const waiting = backgroundWorkHoldsCompletion(tasks) || tasks.every(isPullRequestWatchTask); const items = tasks .map((task): PendingBackgroundWorkItem => { const description = task.description?.trim();