Skip to content

Commit 070952e

Browse files
t3dotggclaude
andcommitted
fix(threads): a watch also holds a thread that never ran
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 7db3d7d commit 070952e

5 files changed

Lines changed: 74 additions & 18 deletions

File tree

‎packages/client-runtime/src/state/entities.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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.
176176
function 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‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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(
@@ -249,6 +246,16 @@ export function deriveThreadRuntime(
249246
pullRequests: projection.thread.pullRequests,
250247
}),
251248
);
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;
252259
return {
253260
status: usageLimitedRun
254261
? "failed"

‎packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,17 @@ describe("derivePendingBackgroundWork kinds", () => {
564564
expect(backgroundWorkHoldsCompletion(tasks)).toBe(true);
565565
});
566566

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+
567578
it("lists no watch while a run is active", () => {
568579
expect(
569580
derivePendingBackgroundWork({

‎packages/shared/src/orchestrationV2PendingBackgroundWork.ts‎

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ function backgroundWorkKindHoldsCompletion(kind: PendingBackgroundWorkTask["kind
8989

9090
type PendingBackgroundWorkRun = Pick<OrchestrationV2Run, "id" | "ordinal" | "status">;
9191

92+
type PendingBackgroundWorkPullRequest = Pick<
93+
ThreadPullRequestLink,
94+
"host" | "repository" | "number" | "url" | "source" | "watch"
95+
>;
96+
9297
type PendingBackgroundWorkProviderThread = Pick<
9398
OrchestrationV2ProviderThread,
9499
"id" | "pendingBackgroundTasks"
@@ -227,11 +232,7 @@ export function derivePendingBackgroundWork(input: {
227232
* pass projection runs so policy cannot drift.
228233
*/
229234
readonly runs?: ReadonlyArray<PendingBackgroundWorkRun>;
230-
readonly pullRequests?:
231-
| ReadonlyArray<
232-
Pick<ThreadPullRequestLink, "host" | "repository" | "number" | "url" | "source" | "watch">
233-
>
234-
| undefined;
235+
readonly pullRequests?: ReadonlyArray<PendingBackgroundWorkPullRequest> | undefined;
235236
}): ReadonlyArray<PendingBackgroundWorkTask> {
236237
const hasActiveRun =
237238
input.hasActiveRun ??
@@ -242,6 +243,10 @@ export function derivePendingBackgroundWork(input: {
242243
if (hasActiveRun) {
243244
return [];
244245
}
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+
}
245250
if (!isLatestRunSettledForBackgroundWait(input.latestRun)) {
246251
return [];
247252
}
@@ -276,15 +281,23 @@ export function derivePendingBackgroundWork(input: {
276281
byTaskId.set(taskId, pendingTaskFromTurnItem(taskId, item));
277282
}
278283

279-
for (const link of input.pullRequests ?? []) {
280-
if (link.watch === undefined || link.source === "stack-dismissed") continue;
281-
const taskId = `pull-request-watch:${threadPullRequestKeyOf(link)}`;
282-
byTaskId.set(taskId, {
283-
taskId,
284-
description: `Watching pull request #${link.number}`,
285-
kind: "monitor",
286-
});
287-
}
284+
for (const task of pullRequestWatchTasks(input.pullRequests)) byTaskId.set(task.taskId, task);
288285

289286
return Array.from(byTaskId.values());
290287
}
288+
289+
function 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+
}

0 commit comments

Comments
 (0)