authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-03 16:20:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 16:20:17-07:00
logd65cd33a03983751598f6bdce76b402ac7bf2cca
treea6821f858a429a08e7ebe02bd1ee47ee58a13abf
parent00eb8f6183ba4b36a429db08767031ad8ca9d81d
signature Signed by PGP key B5690EEEBB952194

fix(server): Stop ends a dev server left running before a provider switch (#15355)

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

6 files changed, 527 insertions(+), 5 deletions(-)

apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts created+446
...@@ -0,0 +1,446 @@
1import { assert, it } from "@effect/vitest";
2import {
3 CommandId,
4 EventId,
5 MessageId,
6 NodeId,
7 ProjectId,
8 ProviderDriverKind,
9 ProviderInstanceId,
10 ProviderThreadId,
11 ProviderTurnId,
12 RunAttemptId,
13 RunId,
14 ThreadId,
15 TurnItemId,
16 type OrchestrationV2DomainEvent,
17} from "@t3tools/contracts";
18import * as DateTime from "effect/DateTime";
19import * as Effect from "effect/Effect";
20import * as Fiber from "effect/Fiber";
21import * as Queue from "effect/Queue";
22import * as Stream from "effect/Stream";
23import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
24import * as EffectWorker from "./EffectWorker.ts";
25import * as EventSink from "./EventSink.ts";
26import * as Orchestrator from "./Orchestrator.ts";
27import type {
28 ProviderAdapterV2Event,
29 ProviderAdapterV2InterruptInput,
30 ProviderAdapterV2Shape,
31 ProviderAdapterV2TurnInput,
32} from "./ProviderAdapter.ts";
33import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
34import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts";
35import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts";
36
37const driver = ProviderDriverKind.make("codex");
38const instanceId = ProviderInstanceId.make("codex");
39const modelSelection = { instanceId, model: "test-model" };
40
41// Codex turns leave commands running, then the thread moves to another
42// provider thread (a provider switch). Stop on the newer, settled run must
43// reach both provider threads and end all of the Codex work.
44it.effect("Stop reaches background work an earlier provider thread still runs", () =>
45 Effect.scoped(
46 Effect.gen(function* () {
47 const cwd = yield* checkpointWorkspace("background-work-stop");
48 const events = yield* Queue.unbounded<ProviderAdapterV2Event>();
49 const started: ProviderAdapterV2TurnInput[] = [];
50 const interrupts: ProviderAdapterV2InterruptInput[] = [];
51 const adapter: ProviderAdapterV2Shape = {
52 instanceId,
53 driver,
54 getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2),
55 planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }),
56 openSession: (input) =>
57 Effect.gen(function* () {
58 const now = yield* DateTime.now;
59 return {
60 instanceId,
61 driver,
62 providerSessionId: input.providerSessionId,
63 providerSession: {
64 id: input.providerSessionId,
65 driver,
66 providerInstanceId: instanceId,
67 status: "ready",
68 cwd,
69 model: modelSelection.model,
70 capabilities: CodexProviderCapabilitiesV2,
71 createdAt: now,
72 updatedAt: now,
73 lastError: null,
74 },
75 events: Stream.fromQueue(events),
76 ensureThread: ({ threadId }) =>
77 Effect.succeed({
78 id: ProviderThreadId.make(`provider-thread:codex:${threadId}`),
79 driver,
80 providerInstanceId: instanceId,
81 providerSessionId: input.providerSessionId,
82 appThreadId: threadId,
83 ownerNodeId: null,
84 nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" },
85 nativeConversationHeadRef: null,
86 status: "idle",
87 firstRunOrdinal: null,
88 lastRunOrdinal: null,
89 handoffIds: [],
90 forkedFrom: null,
91 createdAt: now,
92 updatedAt: now,
93 }),
94 resumeThread: ({ providerThread }) => Effect.succeed(providerThread),
95 startTurn: (turn) =>
96 Effect.gen(function* () {
97 started.push(turn);
98 yield* Queue.offer(events, {
99 type: "provider_turn.updated",
100 driver,
101 providerTurn: {
102 id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`),
103 providerThreadId: turn.providerThread.id,
104 nodeId: turn.rootNodeId,
105 runAttemptId: turn.attemptId,
106 nativeTurnRef: {
107 driver,
108 nativeId: `native:${turn.attemptId}`,
109 strength: "strong",
110 },
111 ordinal: turn.providerTurnOrdinal,
112 status: "running",
113 startedAt: now,
114 completedAt: null,
115 },
116 });
117 }),
118 steerTurn: () => Effect.die("unused"),
119 interruptTurn: (interrupt) =>
120 Effect.sync(() => {
121 interrupts.push(interrupt);
122 }),
123 respondToRuntimeRequest: () => Effect.die("unused"),
124 readThreadSnapshot: () => Effect.die("unused"),
125 rollbackThread: () => Effect.die("unused"),
126 forkThread: () => Effect.die("unused"),
127 };
128 }),
129 };
130 yield* Effect.gen(function* () {
131 const orchestrator = yield* Orchestrator.OrchestratorV2;
132 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
133 const sink = yield* EventSink.EventSinkV2;
134 const threadId = ThreadId.make("thread:background-work-stop");
135 const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) =>
136 orchestrator.streamDomainEvents.pipe(
137 Stream.filter(predicate),
138 Stream.take(1),
139 Stream.runDrain,
140 Effect.forkScoped,
141 );
142 yield* orchestrator.dispatch({
143 type: "thread.create",
144 commandId: CommandId.make("create"),
145 threadId,
146 projectId: ProjectId.make("project:background-work-stop"),
147 title: "Background work stop",
148 modelSelection,
149 runtimeMode: "full-access",
150 interactionMode: "default",
151 branch: null,
152 worktreePath: cwd,
153 createdBy: "user",
154 creationSource: "web",
155 });
156 const running = yield* watch(
157 (event) => event.type === "provider-turn.updated" && event.payload.status === "running",
158 );
159 yield* orchestrator.dispatch({
160 type: "message.dispatch",
161 commandId: CommandId.make("start-dev-server"),
162 threadId,
163 messageId: MessageId.make("message:start-dev-server"),
164 text: "Start the dev server",
165 attachments: [],
166 dispatchMode: { type: "start_immediately" },
167 createdBy: "user",
168 creationSource: "web",
169 });
170 yield* worker.drain();
171 yield* Fiber.join(running);
172 const first = started[0]!;
173 const codexTurn = (yield* orchestrator.getThreadProjection(threadId)).providerTurns[0]!;
174 const devServerId = TurnItemId.make("turn-item:dev-server");
175 const now = yield* DateTime.now;
176 yield* sink.write({
177 events: [
178 {
179 id: EventId.make("dev-server"),
180 type: "turn-item.updated",
181 threadId,
182 runId: first.runId,
183 occurredAt: now,
184 payload: {
185 id: devServerId,
186 threadId,
187 runId: first.runId,
188 nodeId: first.rootNodeId,
189 providerThreadId: codexTurn.providerThreadId,
190 providerTurnId: codexTurn.id,
191 nativeItemRef: null,
192 parentItemId: null,
193 ordinal: 100,
194 status: "running",
195 title: null,
196 startedAt: now,
197 completedAt: null,
198 updatedAt: now,
199 type: "command_execution",
200 input: "vp run dev --share",
201 },
202 },
203 ],
204 });
205 const settled = yield* watch(
206 (event) =>
207 event.type === "run.updated" &&
208 event.payload.id === first.runId &&
209 event.payload.status === "waiting",
210 );
211 yield* Queue.offer(events, {
212 type: "provider_turn.updated",
213 driver,
214 providerTurn: { ...codexTurn, status: "completed", completedAt: now },
215 });
216 yield* Queue.offer(events, {
217 type: "turn.terminal",
218 driver,
219 providerThreadId: codexTurn.providerThreadId,
220 providerTurnId: codexTurn.id,
221 runOrdinal: first.runOrdinal,
222 status: "completed",
223 failure: null,
224 threadDisposition: "reusable",
225 });
226 yield* Fiber.join(settled);
227 yield* worker.drain();
228
229 const codexProviderThread = (yield* orchestrator.getThreadProjection(threadId))
230 .providerThreads[0]!;
231 // A settled run with its root node, attempt, provider turn and,
232 // optionally, a command or native subagent it left running.
233 const settledRun = (input: {
234 readonly ordinal: number;
235 readonly providerThreadId: ProviderThreadId;
236 readonly runningItem?: { readonly id: TurnItemId; readonly kind: "command" | "subagent" };
237 }) => {
238 const runId = RunId.make(`run:${input.ordinal}`);
239 const attemptId = RunAttemptId.make(`attempt:${input.ordinal}`);
240 const nodeId = NodeId.make(`node:${input.ordinal}`);
241 const providerTurnId = ProviderTurnId.make(`provider-turn:${input.ordinal}`);
242 const events: Array<OrchestrationV2DomainEvent> = [
243 {
244 id: EventId.make(`run:${input.ordinal}`),
245 type: "run.created",
246 threadId,
247 occurredAt: now,
248 payload: {
249 id: runId,
250 threadId,
251 ordinal: input.ordinal,
252 providerInstanceId: instanceId,
253 modelSelection,
254 providerThreadId: input.providerThreadId,
255 userMessageId: MessageId.make(`message:${input.ordinal}`),
256 rootNodeId: nodeId,
257 activeAttemptId: attemptId,
258 status: "completed",
259 requestedAt: now,
260 startedAt: now,
261 completedAt: now,
262 checkpointId: null,
263 contextHandoffId: null,
264 },
265 },
266 {
267 id: EventId.make(`node:${input.ordinal}`),
268 type: "node.updated",
269 threadId,
270 runId,
271 occurredAt: now,
272 payload: {
273 id: nodeId,
274 threadId,
275 runId,
276 parentNodeId: null,
277 rootNodeId: nodeId,
278 kind: "root_turn",
279 status: "completed",
280 countsForRun: true,
281 providerThreadId: input.providerThreadId,
282 providerTurnId,
283 nativeItemRef: null,
284 runtimeRequestId: null,
285 checkpointScopeId: null,
286 startedAt: now,
287 completedAt: now,
288 },
289 },
290 {
291 id: EventId.make(`attempt:${input.ordinal}`),
292 type: "run-attempt.created",
293 threadId,
294 occurredAt: now,
295 payload: {
296 id: attemptId,
297 runId,
298 attemptOrdinal: 1,
299 rootNodeId: nodeId,
300 providerInstanceId: instanceId,
301 providerThreadId: input.providerThreadId,
302 providerTurnId,
303 reason: "initial",
304 status: "completed",
305 startedAt: now,
306 completedAt: now,
307 },
308 },
309 {
310 id: EventId.make(`provider-turn:${input.ordinal}`),
311 type: "provider-turn.updated",
312 threadId,
313 occurredAt: now,
314 payload: {
315 id: providerTurnId,
316 providerThreadId: input.providerThreadId,
317 nodeId,
318 runAttemptId: attemptId,
319 nativeTurnRef: null,
320 ordinal: input.ordinal,
321 status: "completed",
322 startedAt: now,
323 completedAt: now,
324 },
325 },
326 ];
327 const item = input.runningItem;
328 if (item !== undefined) {
329 const base = {
330 id: item.id,
331 threadId,
332 runId,
333 nodeId,
334 providerThreadId: input.providerThreadId,
335 providerTurnId,
336 nativeItemRef: null,
337 parentItemId: null,
338 ordinal: input.ordinal * 100,
339 status: "running",
340 title: null,
341 startedAt: now,
342 completedAt: null,
343 updatedAt: now,
344 } as const;
345 events.push({
346 id: EventId.make(`item:${input.ordinal}`),
347 type: "turn-item.updated",
348 threadId,
349 runId,
350 occurredAt: now,
351 payload:
352 item.kind === "command"
353 ? { ...base, type: "command_execution", input: "vp run test --watch" }
354 : {
355 ...base,
356 // A native subagent item names its own provider thread
357 // but its parent's provider turn.
358 providerThreadId: ProviderThreadId.make("provider-thread:codex-subagent"),
359 type: "subagent",
360 subagentId: NodeId.make(`subagent:${input.ordinal}`),
361 origin: "provider_native",
362 driver,
363 providerInstanceId: instanceId,
364 childThreadId: null,
365 prompt: "Review the change",
366 result: null,
367 },
368 });
369 }
370 return { runId, providerTurnId, events };
371 };
372
373 // Later Codex runs leave a second command and a native subagent. Then
374 // the thread moves on to another provider thread, which also has a
375 // live session.
376 const watcherId = TurnItemId.make("turn-item:watcher");
377 const watcherRun = settledRun({
378 ordinal: 2,
379 providerThreadId: codexProviderThread.id,
380 runningItem: { id: watcherId, kind: "command" },
381 });
382 const reviewerId = TurnItemId.make("turn-item:reviewer");
383 const reviewerRun = settledRun({
384 ordinal: 3,
385 providerThreadId: codexProviderThread.id,
386 runningItem: { id: reviewerId, kind: "subagent" },
387 });
388 const otherProviderThreadId = ProviderThreadId.make("provider-thread:other");
389 const latestRun = settledRun({ ordinal: 4, providerThreadId: otherProviderThreadId });
390 yield* sink.write({
391 events: [
392 ...watcherRun.events,
393 ...reviewerRun.events,
394 {
395 id: EventId.make("provider-thread:other"),
396 type: "provider-thread.updated",
397 threadId,
398 occurredAt: now,
399 payload: {
400 ...codexProviderThread,
401 id: otherProviderThreadId,
402 firstRunOrdinal: 4,
403 lastRunOrdinal: 4,
404 },
405 },
406 ...latestRun.events,
407 ],
408 });
409
410 yield* orchestrator.dispatch({
411 type: "run.interrupt",
412 commandId: CommandId.make("stop-background-work"),
413 threadId,
414 runId: latestRun.runId,
415 });
416 yield* worker.drain();
417
418 // Stop reaches both provider threads. The Codex one is interrupted at
419 // its latest pending work, the subagent's parent turn, so its settle
420 // covers all three Codex runs.
421 assert.sameDeepMembers(
422 interrupts.map((interrupt) => [interrupt.providerThread.id, interrupt.providerTurnId]),
423 [
424 [otherProviderThreadId, latestRun.providerTurnId],
425 [codexProviderThread.id, reviewerRun.providerTurnId],
426 ],
427 );
428 const after = yield* orchestrator.getThreadProjection(threadId);
429 assert.deepEqual(
430 [devServerId, watcherId, reviewerId].map(
431 (id) => after.turnItems.find((item) => item.id === id)?.status,
432 ),
433 ["interrupted", "interrupted", "interrupted"],
434 );
435 }).pipe(
436 Effect.provide(
437 makeOrchestratorV2ReplayLayerWithRegistry(
438 { name: "background-work-stop" },
439 ProviderAdapterRegistry.makeSingleLayer(adapter),
440 { runEffectWorker: false },
441 ),
442 ),
443 );
444 }),
445 ),
446);
apps/server/src/orchestration-v2/EffectWorker.ts+3-1
...@@ -169,10 +169,12 @@ export const executorLayer: Layer.Layer<...@@ -169,10 +169,12 @@ export const executorLayer: Layer.Layer<
169 // The provider has stopped what it still ran and reported it.169 // The provider has stopped what it still ran and reported it.
170 // Whatever the thread still shows on that provider thread is170 // Whatever the thread still shows on that provider thread is
171 // work no process will report on, so the Stop ends it too.171 // work no process will report on, so the Stop ends it too.
172 // One Stop can interrupt several provider threads, so the
173 // settle is keyed by effect, not by the Stop command.
172 Effect.andThen(174 Effect.andThen(
173 threads.dispatch({175 threads.dispatch({
174 type: "thread.background-work.settle",176 type: "thread.background-work.settle",
175 commandId: CommandId.make(`${effect.commandId}:background-work-settled`),177 commandId: CommandId.make(`${effect.id}:background-work-settled`),
176 threadId: effect.threadId,178 threadId: effect.threadId,
177 providerThreadId: effect.request.providerThreadId,179 providerThreadId: effect.request.providerThreadId,
178 providerTurnId: effect.request.providerTurnId,180 providerTurnId: effect.request.providerTurnId,
apps/server/src/orchestration-v2/Orchestrator.ts+59
...@@ -8108,6 +8108,63 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -8108,6 +8108,63 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
8108 cause: `Run ${command.runId} is not interruptible.`,8108 cause: `Run ${command.runId} is not interruptible.`,
8109 });8109 });
8110 }8110 }
8111 // Background work can outlive a provider switch, such as a Codex dev
8112 // server left running when the thread moved to Claude. Stop also reaches
8113 // each other live provider thread that owns pending work; that
8114 // interrupt's settle follow-up ends what its provider leaves behind.
8115 // Work on a dead session is settled with this run's below.
8116 const otherProviderInterrupts: Array<PendingOrchestrationEffectV2> = [];
8117 if (hasBackgroundWork) {
8118 const runOrdinals = new Map(
8119 projection.runs.map((candidate) => [candidate.id, candidate.ordinal]),
8120 );
8121 const runOrdinalOf = (item: (typeof projection.turnItems)[number]) =>
8122 item.runId === null ? -1 : (runOrdinals.get(item.runId) ?? -1);
8123 // Each provider thread is interrupted at its latest pending work: that
8124 // turn's run bounds the settle follow-up, which must cover all of the
8125 // thread's work. The target comes from the item's provider turn, since
8126 // a native subagent item names its own provider thread but its
8127 // parent's turn; interrupting the parent's turn reaches the subagent.
8128 const latestTurnByProviderThread = new Map<
8129 OrchestrationV2ProviderThread["id"],
8130 { readonly turn: (typeof projection.providerTurns)[number]; readonly runOrdinal: number }
8131 >();
8132 for (const item of pendingBackgroundTurnItems({
8133 turnItems: projection.turnItems,
8134 runs: projection.runs,
8135 })) {
8136 const turn = projection.providerTurns.find(
8137 (candidate) => candidate.id === item.providerTurnId,
8138 );
8139 if (turn === undefined || turn.providerThreadId === providerThread.id) continue;
8140 const runOrdinal = runOrdinalOf(item);
8141 const latest = latestTurnByProviderThread.get(turn.providerThreadId);
8142 if (latest === undefined || runOrdinal > latest.runOrdinal) {
8143 latestTurnByProviderThread.set(turn.providerThreadId, { turn, runOrdinal });
8144 }
8145 }
8146 for (const { turn } of latestTurnByProviderThread.values()) {
8147 const owner = projection.providerThreads.find(
8148 (candidate) => candidate.id === turn.providerThreadId,
8149 );
8150 if (owner === undefined || owner.providerSessionId === null) continue;
8151 const ownerSession = yield* providerSessions
8152 .get(owner.providerSessionId)
8153 .pipe(Effect.orElseSucceed(() => Option.none()));
8154 if (Option.isNone(ownerSession)) continue;
8155 otherProviderInterrupts.push({
8156 id: `effect:${command.commandId}:provider-turn.interrupt:${turn.id}`,
8157 commandId: command.commandId,
8158 threadId: command.threadId,
8159 request: {
8160 type: "provider-turn.interrupt",
8161 providerSessionId: owner.providerSessionId,
8162 providerThreadId: owner.id,
8163 providerTurnId: turn.id,
8164 },
8165 });
8166 }
8167 }
8111 // Stop on a settled thread's background work. Its process may be gone8168 // Stop on a settled thread's background work. Its process may be gone
8112 // (released, restarted) and only the projection still shows the work;8169 // (released, restarted) and only the projection still shows the work;
8113 // the settle follow-up ends whatever no provider reports ending.8170 // the settle follow-up ends whatever no provider reports ending.
...@@ -8139,6 +8196,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -8139,6 +8196,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
8139 throughRunOrdinal: run.ordinal,8196 throughRunOrdinal: run.ordinal,
8140 now,8197 now,
8141 });8198 });
8199 yield* Ref.update(effects, (existing) => [...existing, ...otherProviderInterrupts]);
8142 return undefined;8200 return undefined;
8143 }8201 }
8144 if (providerThread.providerSessionId === null) {8202 if (providerThread.providerSessionId === null) {
...@@ -8200,6 +8258,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -8200,6 +8258,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
8200 providerTurnId: providerTurn.id,8258 providerTurnId: providerTurn.id,
8201 },8259 },
8202 } satisfies PendingOrchestrationEffectV2,8260 } satisfies PendingOrchestrationEffectV2,
8261 ...otherProviderInterrupts,
8203 ]);8262 ]);
8204 return undefined;8263 return undefined;
8205 });8264 });
apps/server/src/orchestration-v2/ProjectionSettlement.test.ts+1-1
...@@ -247,7 +247,7 @@ it.effect.each([...@@ -247,7 +247,7 @@ it.effect.each([
247 forkedFrom: null,247 forkedFrom: null,
248 createdAt: old,248 createdAt: old,
249 updatedAt: old,249 updatedAt: old,
250 pendingBackgroundTasks: [{ taskId: "running-task", kind: "command" }],250 pendingBackgroundTasks: [{ taskId: "running-task", kind: "subagent" }],
251 },251 },
252 });252 });
253 const candidates = yield* store.getSettlementCandidates();253 const candidates = yield* store.getSettlementCandidates();
apps/server/src/orchestration-v2/ThreadSettlementService.test.ts+14-1
...@@ -138,12 +138,25 @@ describe("isAutoSettlementCandidate", () => {...@@ -138,12 +138,25 @@ describe("isAutoSettlementCandidate", () => {
138 ).toBe(false);138 ).toBe(false);
139 expect(139 expect(
140 ThreadSettlementService.isAutoSettlementCandidate(140 ThreadSettlementService.isAutoSettlementCandidate(
141 shell({ pendingBackgroundTasks: [{ label: "task" }] as never }),141 shell({ pendingBackgroundTasks: [{ taskId: "review", kind: "subagent" }] }),
142 NOW_MS,142 NOW_MS,
143 ),143 ),
144 ).toBe(false);144 ).toBe(false);
145 });145 });
146146
147 it("settles a thread whose only background work is a command left running", () => {
148 expect(
149 ThreadSettlementService.isAutoSettlementCandidate(
150 shell({
151 pendingBackgroundTasks: [
152 { taskId: "dev", kind: "command", description: "vp run dev --share" },
153 ],
154 }),
155 NOW_MS,
156 ),
157 ).toBe(true);
158 });
159
147 it("keeps snoozed threads parked until they wake early on error or completion", () => {160 it("keeps snoozed threads parked until they wake early on error or completion", () => {
148 const snoozed = shell({161 const snoozed = shell({
149 snoozedUntil: at(60 * 60 * 1_000),162 snoozedUntil: at(60 * 60 * 1_000),
apps/server/src/orchestration-v2/ThreadSettlementService.ts+4-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
1import { resolveProjectSettings } from "@t3tools/shared/projectSettings";2import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
2import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";3import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
3import {4import {
...@@ -138,9 +139,10 @@ export function isAutoSettlementCandidate(...@@ -138,9 +139,10 @@ export function isAutoSettlementCandidate(
138 if (thread.pinnedAt != null || thread.autoSettleDisabledAt != null) return false;139 if (thread.pinnedAt != null || thread.autoSettleDisabledAt != null) return false;
139 // Blocked-on-you work must never park behind a settled override.140 // Blocked-on-you work must never park behind a settled override.
140 if (thread.pendingRuntimeRequest !== null) return false;141 if (thread.pendingRuntimeRequest !== null) return false;
141 // A live run — or post-settlement background work — is not staleness.142 // A live run, or background work that will wake the agent, is not
143 // staleness. A dev server left running is: the agent is done.
142 if (thread.activityRunStatus != null) return false;144 if (thread.activityRunStatus != null) return false;
143 if ((thread.pendingBackgroundTasks?.length ?? 0) > 0) return false;145 if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) return false;
144 if (threadHasQueuedTurnStart(thread, nowMs)) return false;146 if (threadHasQueuedTurnStart(thread, nowMs)) return false;
145 const snoozedUntilMs = toMillis(thread.snoozedUntil);147 const snoozedUntilMs = toMillis(thread.snoozedUntil);
146 if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return true;148 if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return true;