1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { assert, describe, expect, it } from "@effect/vitest";
3import * as DateTime from "effect/DateTime";
4import {
5 DEFAULT_SERVER_SETTINGS,
6 CommandId,
7 ProjectId,
8 EventId,
9 RunId,
10 TurnItemId,
11 ManagedWorkspaceError,
12 DevServerError,
13 ProviderInstanceId,
14 ProviderSessionId,
15 ThreadId,
16 type OrchestrationProjectShell,
17 type OrchestrationV2AppThread,
18 type OrchestrationV2DomainEvent,
19 type OrchestrationV2StoredEvent,
20 type TerminalSummary,
21 type OrchestrationV2ShellSnapshot,
22 type OrchestrationV2ThreadShell,
23 type PullRequestSummary,
24 type MergebotPullRequestDetails,
25 type OrchestrationV2ServerCommand,
26 type ServerSettings as ContractServerSettings,
27 type ServerSettingsPatch,
28} from "@t3tools/contracts";
29import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
30import * as Crypto from "effect/Crypto";
31import * as Deferred from "effect/Deferred";
32import * as Effect from "effect/Effect";
33import * as Fiber from "effect/Fiber";
34import * as FileSystem from "effect/FileSystem";
35import * as Layer from "effect/Layer";
36import * as PubSub from "effect/PubSub";
37import * as Queue from "effect/Queue";
38import * as Ref from "effect/Ref";
39import * as Stream from "effect/Stream";
40import { TestClock } from "effect/testing";
41
42import * as GitManager from "../git/GitManager.ts";
43import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
44import * as PullRequestService from "../pullRequest/PullRequestService.ts";
45import * as ServerActivation from "../serverActivation.ts";
46import * as ServerSettings from "../serverSettings.ts";
47import * as TerminalManager from "../terminal/Manager.ts";
48import * as PreviewManager from "../preview/Manager.ts";
49import * as ProjectStore from "./ProjectStore.ts";
50import * as Orchestrator from "./Orchestrator.ts";
51import * as ProjectionStore from "./ProjectionStore.ts";
52import * as ThreadSettlementService from "./ThreadSettlementService.ts";
53import * as ManagedWorkspaces from "../workspace/ManagedWorkspaces.ts";
54import * as DevServer from "../workspace/DevServer.ts";
55import * as MergebotQueue from "../mergebot/MergebotQueue.ts";
56import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts";
57
58const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z");
59const DAY_MS = 24 * 60 * 60 * 1_000;
60
61function at(offsetMs: number): DateTime.Utc {
62 return DateTime.makeUnsafe(NOW_MS + offsetMs);
63}
64
65type SettlementShell = OrchestrationV2ThreadShell &
66 Pick<ProjectionStore.ProjectionSettlementCandidate, "latestUserAuthoredMessageAt">;
67
68// A fixture's user message is one the user wrote unless the test sets
69// latestUserAuthoredMessageAt on its own.
70function shell(overrides: Partial<SettlementShell> = {}): SettlementShell {
71 return {
72 id: ThreadId.make("thread-1"),
73 projectId: ProjectId.make("project-1"),
74 title: "Thread",
75 providerInstanceId: ProviderInstanceId.make("codex"),
76 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
77 runtimeMode: "full-access",
78 interactionMode: "default",
79 worktreePath: null,
80 activeProviderThreadId: null,
81 lineage: {
82 rootThreadId: ThreadId.make("thread-1"),
83 parentThreadId: null,
84 relationshipToParent: null,
85 },
86 forkedFrom: null,
87 createdBy: "user",
88 creationSource: "web",
89 activeRunId: null,
90 latestVisibleMessage: null,
91 hasActionableProposedPlan: false,
92 itemCount: 0,
93 visibleItemCount: 0,
94 lastVisitedAt: null,
95 deletedAt: null,
96 branch: null,
97 linkedPullRequest: null,
98 status: "idle",
99 activityRunStatus: null,
100 pendingRuntimeRequest: null,
101 pendingBackgroundTasks: [],
102 latestRunId: null,
103 latestRunRequestedAt: null,
104 latestRunStartedAt: null,
105 latestRunCompletedAt: null,
106 latestUserMessageAt: null,
107 latestUserAuthoredMessageAt: overrides.latestUserMessageAt ?? null,
108 createdAt: at(-30 * DAY_MS),
109 updatedAt: at(-10 * DAY_MS),
110 archivedAt: null,
111 settledOverride: null,
112 settledAt: null,
113 snoozedUntil: null,
114 snoozedAt: null,
115 pinnedAt: null,
116 ...overrides,
117 };
118}
119
120describe("isAutoSettlementCandidate", () => {
121 it("excludes overridden, pinned, blocked, and working threads", () => {
122 expect(ThreadSettlementService.isAutoSettlementCandidate(shell(), NOW_MS)).toBe(true);
123 expect(
124 ThreadSettlementService.isAutoSettlementCandidate(shell({ archivedAt: at(-1) }), NOW_MS),
125 ).toBe(false);
126 expect(
127 ThreadSettlementService.isAutoSettlementCandidate(
128 shell({ settledOverride: "settled" }),
129 NOW_MS,
130 ),
131 ).toBe(false);
132 expect(
133 ThreadSettlementService.isAutoSettlementCandidate(
134 shell({ settledOverride: "active" }),
135 NOW_MS,
136 ),
137 ).toBe(false);
138 expect(
139 ThreadSettlementService.isAutoSettlementCandidate(shell({ pinnedAt: at(-1) }), NOW_MS),
140 ).toBe(false);
141 expect(
142 ThreadSettlementService.isAutoSettlementCandidate(
143 shell({ autoSettleDisabledAt: at(-1) }),
144 NOW_MS,
145 ),
146 ).toBe(false);
147 expect(
148 ThreadSettlementService.isAutoSettlementCandidate(
149 shell({ activityRunStatus: "running" }),
150 NOW_MS,
151 ),
152 ).toBe(false);
153 expect(
154 ThreadSettlementService.isAutoSettlementCandidate(
155 shell({
156 pendingRuntimeRequest: { kind: "approval" } as never,
157 }),
158 NOW_MS,
159 ),
160 ).toBe(false);
161 expect(
162 ThreadSettlementService.isAutoSettlementCandidate(
163 shell({ pendingBackgroundTasks: [{ taskId: "review", kind: "subagent" }] }),
164 NOW_MS,
165 ),
166 ).toBe(false);
167 });
168
169 it("keeps a thread with a running background command active", () => {
170 expect(
171 ThreadSettlementService.isAutoSettlementCandidate(
172 shell({
173 pendingBackgroundTasks: [
174 { taskId: "dev", kind: "command", description: "vp run dev --share" },
175 ],
176 }),
177 NOW_MS,
178 ),
179 ).toBe(false);
180 });
181
182 it("keeps snoozed threads parked until they wake early on error or completion", () => {
183 const snoozed = shell({
184 snoozedUntil: at(60 * 60 * 1_000),
185 snoozedAt: at(-60 * 60 * 1_000),
186 });
187 expect(ThreadSettlementService.isAutoSettlementCandidate(snoozed, NOW_MS)).toBe(false);
188 expect(
189 ThreadSettlementService.isAutoSettlementCandidate(
190 shell({ ...snoozed, status: "failed", latestRunCompletedAt: at(-30 * 60 * 1_000) }),
191 NOW_MS,
192 ),
193 ).toBe(true);
194 expect(
195 ThreadSettlementService.isAutoSettlementCandidate(
196 shell({ ...snoozed, latestRunCompletedAt: at(-30 * 60 * 1_000) }),
197 NOW_MS,
198 ),
199 ).toBe(true);
200 expect(
201 ThreadSettlementService.isAutoSettlementCandidate(
202 shell({ ...snoozed, status: "failed", latestRunCompletedAt: at(-2 * 60 * 60 * 1_000) }),
203 NOW_MS,
204 ),
205 ).toBe(false);
206 expect(
207 ThreadSettlementService.isAutoSettlementCandidate(
208 shell({ ...snoozed, status: "failed", latestRunCompletedAt: snoozed.snoozedAt }),
209 NOW_MS,
210 ),
211 ).toBe(false);
212 expect(
213 ThreadSettlementService.isAutoSettlementCandidate(
214 shell({ ...snoozed, status: "failed", latestRunCompletedAt: null }),
215 NOW_MS,
216 ),
217 ).toBe(false);
218 // Expired snooze is no longer a park.
219 expect(
220 ThreadSettlementService.isAutoSettlementCandidate(
221 shell({ ...snoozed, snoozedUntil: at(-1) }),
222 NOW_MS,
223 ),
224 ).toBe(true);
225 });
226});
227
228describe("threadHasQueuedTurnStart", () => {
229 it("holds a fresh unadopted user message inside the grace window only", () => {
230 const fresh = shell({ latestUserMessageAt: at(-1_000) });
231 expect(ThreadSettlementService.threadHasQueuedTurnStart(fresh, NOW_MS)).toBe(true);
232 // Adoption stamps the run with the message time, clearing the hold.
233 expect(
234 ThreadSettlementService.threadHasQueuedTurnStart(
235 shell({
236 latestUserMessageAt: at(-1_000),
237 latestRunId: "run-1" as never,
238 latestRunRequestedAt: at(-500),
239 }),
240 NOW_MS,
241 ),
242 ).toBe(false);
243 // Outside the grace window the stale message no longer blocks.
244 expect(
245 ThreadSettlementService.threadHasQueuedTurnStart(
246 shell({ latestUserMessageAt: at(-ThreadSettlementService.QUEUED_TURN_START_GRACE_MS - 1) }),
247 NOW_MS,
248 ),
249 ).toBe(false);
250 // Client clocks ahead of the server must not extend the hold.
251 expect(
252 ThreadSettlementService.threadHasQueuedTurnStart(
253 shell({ latestUserMessageAt: at(ThreadSettlementService.QUEUED_TURN_START_GRACE_MS + 1) }),
254 NOW_MS,
255 ),
256 ).toBe(false);
257 // A failed start clears the hold immediately.
258 expect(
259 ThreadSettlementService.threadHasQueuedTurnStart(
260 shell({ latestUserMessageAt: at(-1_000), status: "failed" }),
261 NOW_MS,
262 ),
263 ).toBe(false);
264 });
265});
266
267describe("resolveAutoSettlementAt", () => {
268 it("keeps canonical work PR links while ignoring their cached lifecycle", () => {
269 const input = {
270 thread: shell({
271 latestUserMessageAt: at(-4 * DAY_MS),
272 pullRequests: [link(1)],
273 }),
274 pullRequest: null,
275 usePullRequestSnapshots: false,
276 nowMs: NOW_MS,
277 autoSettleAfterDays: 3,
278 autoSettleOnMerge: true,
279 autoSettleKeepPullRequests: true,
280 };
281 expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toBeNull();
282 expect(
283 ThreadSettlementService.resolveAutoSettlementAt({
284 ...input,
285 autoSettleKeepPullRequests: false,
286 }),
287 ).toEqual(at(-4 * DAY_MS));
288 expect(
289 ThreadSettlementService.resolveAutoSettlementAt({
290 ...input,
291 pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) },
292 }),
293 ).toEqual(at(-4 * DAY_MS));
294 });
295
296 it("keeps threads with a PR from inactivity settlement only when asked", () => {
297 const input = {
298 thread: shell({
299 latestUserMessageAt: at(-4 * DAY_MS),
300 linkedPullRequest: {
301 projectId: ProjectId.make("project-1"),
302 repository: "owner/repo",
303 number: 1,
304 url: "https://example.test/owner/repo/pull/1",
305 },
306 }),
307 pullRequest: null,
308 nowMs: NOW_MS,
309 autoSettleAfterDays: 3,
310 autoSettleOnMerge: true,
311 autoSettleKeepPullRequests: true,
312 };
313 expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toBeNull();
314 expect(
315 ThreadSettlementService.resolveAutoSettlementAt({
316 ...input,
317 autoSettleKeepPullRequests: false,
318 }),
319 ).toEqual(at(-4 * DAY_MS));
320 expect(
321 ThreadSettlementService.resolveAutoSettlementAt({
322 ...input,
323 pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) },
324 }),
325 ).toEqual(at(-4 * DAY_MS));
326 });
327
328 it("uses the latest activity time when the inactivity window elapses", () => {
329 const idle = shell({
330 latestUserMessageAt: at(-4 * DAY_MS),
331 latestRunRequestedAt: at(-4 * DAY_MS),
332 latestRunStartedAt: at(-4 * DAY_MS),
333 latestRunCompletedAt: at(-3 * DAY_MS),
334 });
335 const input = {
336 thread: idle,
337 pullRequest: null,
338 nowMs: NOW_MS,
339 autoSettleAfterDays: 2,
340 autoSettleOnMerge: true,
341 };
342 expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toEqual(at(-3 * DAY_MS));
343 expect(
344 ThreadSettlementService.resolveAutoSettlementAt({ ...input, autoSettleAfterDays: 5 }),
345 ).toBeNull();
346 expect(
347 ThreadSettlementService.resolveAutoSettlementAt({ ...input, autoSettleAfterDays: null }),
348 ).toBeNull();
349 expect(
350 ThreadSettlementService.resolveAutoSettlementAt({ ...input, thread: shell() }),
351 ).toBeNull();
352 });
353
354 it.each(["manual", "timer"])("restarts inactivity on a %s snooze wake", (wake) => {
355 const thread = {
356 ...shell({ latestRunCompletedAt: at(-4 * DAY_MS) }),
357 ...(wake === "manual"
358 ? { lastSnoozeWakeAt: at(-1_000) }
359 : { snoozedAt: at(-3 * DAY_MS), snoozedUntil: at(-1_000) }),
360 };
361 const input = {
362 thread,
363 pullRequest: null,
364 nowMs: NOW_MS,
365 autoSettleAfterDays: 1,
366 autoSettleOnMerge: false,
367 };
368 expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toBeNull();
369 expect(
370 ThreadSettlementService.resolveAutoSettlementAt({ ...input, nowMs: NOW_MS + DAY_MS }),
371 ).toEqual(at(-1_000));
372 });
373
374 it("settles on merge only after the user's last action and preserves the activity time", () => {
375 const thread = shell({
376 latestUserMessageAt: at(-2 * 60 * 60 * 1_000),
377 latestRunCompletedAt: at(-90 * 60 * 1_000),
378 });
379 const input = {
380 thread,
381 pullRequest: {
382 state: "merged" as const,
383 mergedAt: DateTime.formatIso(at(-60 * 60 * 1_000)),
384 },
385 nowMs: NOW_MS,
386 autoSettleAfterDays: null,
387 autoSettleOnMerge: true,
388 };
389 expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toEqual(at(-90 * 60 * 1_000));
390 expect(
391 ThreadSettlementService.resolveAutoSettlementAt({ ...input, autoSettleOnMerge: false }),
392 ).toBeNull();
393 expect(
394 ThreadSettlementService.resolveAutoSettlementAt({
395 ...input,
396 pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-3 * 60 * 60 * 1_000)) },
397 }),
398 ).toBeNull();
399 // A thread without messages still has a stable timestamp when its PR closes.
400 expect(
401 ThreadSettlementService.resolveAutoSettlementAt({
402 ...input,
403 thread: shell(),
404 pullRequest: { state: "closed", closedAt: DateTime.formatIso(at(-1)) },
405 autoSettleOnMerge: false,
406 }),
407 ).toEqual(shell().createdAt);
408 });
409
410 it("settles on merge after agent-started runs, but not after the user writes again", () => {
411 // A background command stopped after the merge and its notification
412 // started a run. Only the user's own messages hold a merged thread open.
413 const woken = shell({
414 latestUserMessageAt: at(-30 * 60 * 1_000),
415 latestUserAuthoredMessageAt: at(-2 * 60 * 60 * 1_000),
416 latestRunRequestedAt: at(-30 * 60 * 1_000),
417 latestRunCompletedAt: at(-29 * 60 * 1_000),
418 });
419 const input = {
420 thread: woken,
421 pullRequest: { state: "merged" as const, mergedAt: DateTime.formatIso(at(-60 * 60 * 1_000)) },
422 nowMs: NOW_MS,
423 autoSettleAfterDays: null,
424 autoSettleOnMerge: true,
425 };
426 expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toEqual(at(-29 * 60 * 1_000));
427 expect(
428 ThreadSettlementService.resolveAutoSettlementAt({
429 ...input,
430 thread: { ...woken, latestUserAuthoredMessageAt: at(-30 * 60 * 1_000) },
431 }),
432 ).toBeNull();
433 });
434
435 it("settles inactive threads even when their pull request remains open", () => {
436 const input = {
437 thread: shell({ latestUserMessageAt: at(-30 * DAY_MS) }),
438 pullRequest: { state: "open" as const },
439 nowMs: NOW_MS,
440 autoSettleAfterDays: 2,
441 autoSettleOnMerge: true,
442 };
443 expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toEqual(at(-30 * DAY_MS));
444 expect(
445 ThreadSettlementService.resolveAutoSettlementAt({ ...input, autoSettleAfterDays: null }),
446 ).toBeNull();
447 expect(
448 ThreadSettlementService.resolveAutoSettlementAt({
449 ...input,
450 thread: shell({ latestUserMessageAt: at(-DAY_MS) }),
451 }),
452 ).toBeNull();
453 });
454});
455
456const NOW = "2026-08-28T12:00:00.000Z";
457const PROJECT_ID = ProjectId.make("settlement-project");
458const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project");
459
460describe("autoSettlementSettingsKey", () => {
461 it("tracks PR retention changes at environment and project scope", () => {
462 const base = ThreadSettlementService.autoSettlementSettingsKey(DEFAULT_SERVER_SETTINGS);
463 expect(
464 ThreadSettlementService.autoSettlementSettingsKey({
465 ...DEFAULT_SERVER_SETTINGS,
466 sidebarAutoSettleKeepPullRequests: true,
467 }),
468 ).not.toBe(base);
469 expect(
470 ThreadSettlementService.autoSettlementSettingsKey({
471 ...DEFAULT_SERVER_SETTINGS,
472 projectSettingsOverrides: {
473 [PROJECT_ID]: { sidebarAutoSettleKeepPullRequests: true },
474 },
475 }),
476 ).not.toBe(base);
477 });
478
479 it("distinguishes a project that inherits the threshold from one that disables it", () => {
480 const inherits = ThreadSettlementService.autoSettlementSettingsKey({
481 ...DEFAULT_SERVER_SETTINGS,
482 projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: true } },
483 });
484 const never = ThreadSettlementService.autoSettlementSettingsKey({
485 ...DEFAULT_SERVER_SETTINGS,
486 projectSettingsOverrides: {
487 [PROJECT_ID]: { sidebarAutoSettleOnMerge: true, sidebarAutoSettleAfterDays: null },
488 },
489 });
490 assert.notStrictEqual(inherits, never);
491 });
492
493 it("ignores project overrides that do not touch settlement", () => {
494 const base = ThreadSettlementService.autoSettlementSettingsKey({
495 ...DEFAULT_SERVER_SETTINGS,
496 projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: false } },
497 });
498 const unrelated = ThreadSettlementService.autoSettlementSettingsKey({
499 ...DEFAULT_SERVER_SETTINGS,
500 projectSettingsOverrides: {
501 [LINKED_PROJECT_ID]: { defaultThreadEnvMode: "worktree" },
502 [PROJECT_ID]: { sidebarAutoSettleOnMerge: false, defaultAutoPull: true },
503 },
504 });
505 assert.strictEqual(base, unrelated);
506 });
507});
508
509type SettlementCommand = Extract<
510 OrchestrationV2ServerCommand,
511 {
512 readonly type: "thread.auto-settle" | "thread.archive" | "thread.ci-failed" | "thread.unsettle";
513 }
514>;
515
516const testCrypto = Crypto.make({
517 randomBytes: (size) => new Uint8Array(size).fill(1),
518 digest: (_algorithm, data) => Effect.succeed(data),
519});
520
521function makeProject(
522 id: ProjectId = PROJECT_ID,
523 workspaceRoot = "/workspace/project",
524): OrchestrationProjectShell {
525 return {
526 id,
527 title: `Project ${id}`,
528 workspaceRoot,
529 defaultModelSelection: null,
530 scripts: [],
531 createdAt: "2026-08-01T00:00:00.000Z",
532 updatedAt: NOW,
533 };
534}
535
536function makeThread(id: string, overrides: Partial<SettlementShell> = {}) {
537 return {
538 ...shell({
539 id: ThreadId.make(id),
540 projectId: PROJECT_ID,
541 title: id,
542 createdAt: DateTime.makeUnsafe("2026-08-01T00:00:00.000Z"),
543 updatedAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"),
544 latestUserMessageAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"),
545 ...overrides,
546 }),
547 lastVisitedAt: overrides.lastVisitedAt ?? null,
548 };
549}
550
551type SettlementSnapshot = Omit<OrchestrationV2ShellSnapshot, "threads"> & {
552 readonly threads: ReadonlyArray<SettlementShell>;
553};
554
555function makeSnapshot(
556 threads: ReadonlyArray<SettlementShell>,
557 projects: ReadonlyArray<OrchestrationProjectShell> = [makeProject()],
558): SettlementSnapshot {
559 return {
560 schemaVersion: 1,
561 snapshotSequence: 1,
562 projects,
563 threads,
564 archivedThreads: [],
565 };
566}
567
568function makePullRequestSummary(input: {
569 readonly projectId: ProjectId;
570 readonly repository: string;
571 readonly number: number;
572 readonly state: "open" | "closed" | "merged";
573 readonly updatedAt?: string;
574}): PullRequestSummary {
575 return {
576 provider: SourceControlProviderKind.make("github"),
577 projectId: input.projectId,
578 repository: input.repository,
579 number: input.number,
580 title: "Pull request",
581 url: `https://example.test/${input.repository}/pull/${input.number}`,
582 state: input.state,
583 headBranch: "feature",
584 baseBranch: "main",
585 updatedAt: input.updatedAt ?? NOW,
586 };
587}
588
589function makeBranchPullRequest(state: "open" | "closed" | "merged") {
590 return {
591 number: 42,
592 title: "Pull request",
593 url: "https://example.test/owner/repository/pull/42",
594 baseRef: "main",
595 headRef: "feature",
596 state,
597 repositoryKey: "example.test/owner/repository",
598 updatedAt: NOW,
599 closedAt: state === "closed" ? NOW : null,
600 mergedAt: state === "merged" ? NOW : null,
601 } satisfies GitManager.GitBranchPullRequest;
602}
603
604function terminal(threadId: ThreadId): TerminalSummary {
605 return {
606 threadId,
607 terminalId: "default",
608 cwd: "/workspace/project",
609 worktreePath: null,
610 status: "running",
611 pid: 123,
612 exitCode: null,
613 exitSignal: null,
614 hasRunningSubprocess: true,
615 label: "command",
616 updatedAt: NOW,
617 };
618}
619
620function link(number: number) {
621 return {
622 host: "github.com",
623 repository: "owner/repository",
624 number,
625 url: `https://github.com/owner/repository/pull/${number}`,
626 source: "manual" as const,
627 linkedAt: NOW,
628 snapshot: null,
629 stack: null,
630 };
631}
632
633function mergebotDetail(
634 number: number,
635 overrides: Partial<MergebotPullRequestDetails> = {},
636): MergebotPullRequestDetails {
637 return {
638 number,
639 headSha: "current-head",
640 lifecycle: "open",
641 queueStatus: "not_queued",
642 position: null,
643 mergeEtaAt: null,
644 url: `https://mergebot.example.test/owner/repository/pull/${number}`,
645 title: "Pull request",
646 createdAt: NOW,
647 author: null,
648 additions: 1,
649 deletions: 0,
650 changedFilesCount: 1,
651 labels: [],
652 comments: [],
653 observedAt: Date.parse(NOW),
654 updatedAt: NOW,
655 mergedAt: null,
656 closedAt: null,
657 body: null,
658 draft: false,
659 baseBranch: "main",
660 headBranch: "feature",
661 ciStatus: "passed",
662 hasConflicts: false,
663 checks: [],
664 ...overrides,
665 };
666}
667
668function failedCheck(
669 overrides: Partial<MergebotPullRequestDetails["checks"][number]> = {},
670): MergebotPullRequestDetails["checks"][number] {
671 return {
672 id: "check-1",
673 name: "build",
674 workflowName: null,
675 status: "completed",
676 conclusion: "failure",
677 url: null,
678 startedAt: null,
679 completedAt: NOW,
680 ...overrides,
681 };
682}
683
684interface HarnessOptions {
685 readonly snapshot: SettlementSnapshot;
686 readonly settings?: ContractServerSettings;
687 readonly branchPullRequest?: GitManager.GitManager["Service"]["branchPullRequest"];
688 readonly pullRequestSummary?: PullRequestService.PullRequestService["Service"]["summary"];
689 readonly existingWorktreePaths?: ReadonlyArray<string>;
690 readonly managedThreadIds?: ReadonlyArray<ThreadId>;
691 readonly managedLookup?: ManagedWorkspaces.ManagedWorkspaces["Service"]["isManagedThread"];
692 readonly pullRequestDetail?: PullRequestService.PullRequestService["Service"]["detail"];
693 readonly mergebotDetail?: MergebotQueue.MergebotQueue["Service"]["readPullRequest"];
694 readonly mergebotFind?: MergebotQueue.MergebotQueue["Service"]["findPullRequest"];
695 readonly previousCiNotice?: boolean;
696 readonly previousItemIds?: ReadonlySet<TurnItemId>;
697 readonly settlementTimes?: ReadonlyMap<ThreadId, DateTime.Utc | null>;
698 readonly onDispatch?: (command: SettlementCommand) => Effect.Effect<void>;
699 /** Runs before each settle script start; a failure fails that start. */
700 readonly onScriptRun?: (
701 input: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput,
702 ) => Effect.Effect<void>;
703 /** Runs inside each `close` call. */
704 readonly onCloseTerminals?: () => Effect.Effect<void>;
705 readonly terminals?: ReadonlyArray<TerminalSummary>;
706 readonly onStopUnused?: (threadId: ThreadId) => Effect.Effect<void, DevServerError>;
707 /** Threads `getThread` returns when a `thread.settled` event is handled. */
708 readonly currentThreads?: ReadonlyArray<OrchestrationV2AppThread>;
709}
710
711const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: HarnessOptions) {
712 const activation = yield* Deferred.make<void>();
713 const snapshots = yield* Ref.make(options.snapshot);
714 const snapshotReadCount = yield* Ref.make(0);
715 const snapshotReads = yield* Queue.unbounded<number>();
716 const candidateReads = yield* Ref.make<ReadonlyArray<string | undefined>>([]);
717 const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS);
718 const settingsChanges = yield* PubSub.unbounded<ContractServerSettings>();
719 const mergedPullRequests = yield* PubSub.unbounded<PullRequestService.PullRequestMergeEvent>();
720 const mergebotChanges = yield* PubSub.unbounded<{
721 readonly repository: string;
722 readonly number?: number;
723 }>();
724 const mergebotSubscribed = yield* Deferred.make<void>();
725 const providerDetailReads = yield* Ref.make(0);
726 const commands = yield* Ref.make<ReadonlyArray<SettlementCommand>>([]);
727 const branchCalls = yield* Ref.make<
728 ReadonlyArray<{ readonly cwd: string; readonly branch: string }>
729 >([]);
730 const summaryCalls = yield* Ref.make<
731 ReadonlyArray<{
732 readonly projectId: ProjectId;
733 readonly repository: string;
734 readonly number: number;
735 }>
736 >([]);
737 const summaryRecovery = yield* Ref.make<ReadonlyArray<boolean | undefined>>([]);
738 const invalidatedCwds = yield* Ref.make<ReadonlyArray<string>>([]);
739 const domainEvents = yield* PubSub.unbounded<OrchestrationV2StoredEvent>();
740 const previews = yield* PreviewManager.make.pipe(
741 Effect.provideService(Crypto.Crypto, testCrypto),
742 );
743 const closedTerminals = yield* Queue.unbounded<{ readonly threadId: string }>();
744 const archiveReads = yield* Queue.unbounded<void>();
745 const fullArchiveReadCount = yield* Ref.make(0);
746 const singleArchiveReads = yield* Ref.make<ReadonlyArray<ThreadId>>([]);
747 const scriptRuns =
748 yield* Queue.unbounded<ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput>();
749 const devServerStops = yield* Ref.make<ReadonlyArray<ThreadId>>([]);
750
751 const updateSettings = (patch: ServerSettingsPatch) =>
752 Effect.gen(function* () {
753 const next = applyServerSettingsPatch(yield* Ref.get(settings), patch);
754 yield* Ref.set(settings, next);
755 yield* PubSub.publish(settingsChanges, next);
756 return next;
757 });
758
759 const branchPullRequest: GitManager.GitManager["Service"]["branchPullRequest"] = (input) =>
760 Ref.update(branchCalls, (calls) => [...calls, input]).pipe(
761 Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)),
762 );
763 const pullRequestSummary: PullRequestService.PullRequestService["Service"]["summary"] = (
764 input,
765 readOptions,
766 ) =>
767 Effect.gen(function* () {
768 yield* Ref.update(summaryCalls, (calls) => [...calls, input]);
769 yield* Ref.update(summaryRecovery, (values) => [
770 ...values,
771 readOptions?.recoverTransientFailure,
772 ]);
773 return yield* (
774 options.pullRequestSummary?.(input, readOptions) ??
775 Effect.succeed(
776 makePullRequestSummary({
777 ...input,
778 state: "open",
779 }),
780 )
781 );
782 });
783
784 const dispatch: Orchestrator.OrchestratorV2Shape["dispatch"] = (command) => {
785 if (
786 command.type !== "thread.auto-settle" &&
787 command.type !== "thread.archive" &&
788 command.type !== "thread.ci-failed" &&
789 command.type !== "thread.unsettle"
790 ) {
791 return Effect.die(new Error(`Unexpected command: ${command.type}`));
792 }
793 return Ref.update(commands, (recorded) => [...recorded, command]).pipe(
794 Effect.andThen(options.onDispatch?.(command) ?? Effect.void),
795 Effect.as({ sequence: 1, storedEvents: [] }),
796 );
797 };
798
799 const serverSettings = ServerSettings.ServerSettingsService.of({
800 start: Effect.void,
801 ready: Effect.void,
802 getSettings: Ref.get(settings),
803 updateSettings,
804 updateProviderInstance: () => Effect.die("Unexpected provider mutation"),
805 withSettingsSnapshot: (use) => Ref.get(settings).pipe(Effect.flatMap(use)),
806 streamChanges: Stream.fromPubSub(settingsChanges),
807 subscribeChanges: PubSub.subscribe(settingsChanges).pipe(
808 Effect.map((subscription) => Stream.fromSubscription(subscription)),
809 ),
810 });
811
812 const layerDependencies = Layer.mergeAll(
813 Layer.mock(OrchestrationEventStore.OrchestrationEventStore)({
814 latestAgentSequence: () => Effect.succeed(1),
815 getThreadSettlementTime: (threadId) =>
816 Ref.get(snapshots).pipe(
817 Effect.map(
818 (snapshot) =>
819 options.settlementTimes?.get(threadId) ??
820 snapshot.threads.find((thread) => thread.id === threadId)?.settledAt ??
821 null,
822 ),
823 ),
824 }),
825 Layer.mock(ManagedWorkspaces.ManagedWorkspaces)({
826 isManagedThread:
827 options.managedLookup ??
828 ((threadId) => Effect.succeed(options.managedThreadIds?.includes(threadId) ?? false)),
829 }),
830 Layer.mock(ProjectStore.ProjectStoreV2)({
831 listShells: () => Ref.get(snapshots).pipe(Effect.map((snapshot) => snapshot.projects)),
832 }),
833 Layer.mock(ProjectionStore.ProjectionStoreV2)({
834 getThreadShell: (threadId) =>
835 Ref.update(singleArchiveReads, (reads) => [...reads, threadId]).pipe(
836 Effect.andThen(Queue.offer(archiveReads, undefined)),
837 Effect.andThen(Ref.get(snapshots)),
838 Effect.map(
839 (snapshot) =>
840 [...snapshot.threads, ...snapshot.archivedThreads].find(
841 (thread) => thread.id === threadId,
842 ) ?? null,
843 ),
844 ),
845 getShellSnapshot: (input) =>
846 Ref.update(fullArchiveReadCount, (count) => count + 1).pipe(
847 Effect.andThen(Queue.offer(archiveReads, undefined)),
848 Effect.andThen(Ref.get(snapshots)),
849 Effect.map((snapshot) => ({
850 ...snapshot,
851 threads: snapshot.threads.filter(
852 (thread) => !input?.settledOnly || thread.settledOverride === "settled",
853 ),
854 })),
855 ),
856 getTurnItem: (input) =>
857 Effect.succeed(
858 options.previousCiNotice || options.previousItemIds?.has(input.itemId)
859 ? {
860 ...input,
861 id: input.itemId,
862 type: "notification",
863 source: { kind: "system" },
864 outcome: "failed",
865 summary: "CI failed",
866 runId: null,
867 nodeId: null,
868 providerThreadId: null,
869 providerTurnId: null,
870 nativeItemRef: null,
871 parentItemId: null,
872 ordinal: 0,
873 status: "completed",
874 title: null,
875 startedAt: DateTime.makeUnsafe(NOW),
876 completedAt: DateTime.makeUnsafe(NOW),
877 updatedAt: DateTime.makeUnsafe(NOW),
878 }
879 : null,
880 ),
881 getSettlementCandidates: (threadId) =>
882 Ref.update(candidateReads, (reads) => [...reads, threadId]).pipe(
883 Effect.andThen(Ref.updateAndGet(snapshotReadCount, (count) => count + 1)),
884 Effect.tap((count) => Queue.offer(snapshotReads, count)),
885 Effect.andThen(Ref.get(snapshots)),
886 Effect.map((snapshot) =>
887 threadId === undefined
888 ? snapshot.threads
889 : snapshot.threads.filter((thread) => thread.id === threadId),
890 ),
891 ),
892 getThread: (threadId) => {
893 const thread = options.currentThreads?.find((candidate) => candidate.id === threadId);
894 return thread
895 ? Effect.succeed(thread)
896 : Effect.die(new Error(`Unexpected thread read: ${threadId}`));
897 },
898 }),
899 Layer.mock(Orchestrator.OrchestratorV2)({
900 streamStoredEventsFrom: () => Stream.fromPubSub(domainEvents),
901 dispatch,
902 }),
903 Layer.mock(TerminalManager.TerminalManager)({
904 close: (input) =>
905 Queue.offer(closedTerminals, input).pipe(
906 Effect.andThen(options.onCloseTerminals?.() ?? Effect.void),
907 ),
908 list: ({ threadId }) =>
909 Effect.succeed(
910 options.terminals?.filter((terminal) => terminal.threadId === threadId) ?? [],
911 ),
912 }),
913 Layer.succeed(PreviewManager.PreviewManager, previews),
914 Layer.mock(DevServer.DevServer)({
915 stopUnused: (threadId) =>
916 Ref.update(devServerStops, (stops) => [...stops, threadId]).pipe(
917 Effect.andThen(options.onStopUnused?.(threadId) ?? Effect.void),
918 ),
919 }),
920 Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({
921 runForThread: (input) =>
922 Queue.offer(scriptRuns, input).pipe(
923 Effect.andThen(options.onScriptRun?.(input) ?? Effect.void),
924 Effect.as({ status: "no-script" } as const),
925 ),
926 }),
927 Layer.mock(GitManager.GitManager)({
928 branchPullRequest,
929 invalidateStatus: (cwd) => Ref.update(invalidatedCwds, (cwds) => [...cwds, cwd]),
930 }),
931 Layer.mock(PullRequestService.PullRequestService)({
932 invalidate: () => Effect.void,
933 detail: (input, ...rest) =>
934 Ref.update(providerDetailReads, (count) => count + 1).pipe(
935 Effect.andThen(
936 options.pullRequestDetail?.(input, ...rest) ?? Effect.die("Unexpected detail read"),
937 ),
938 ),
939 summary: pullRequestSummary,
940 subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
941 Effect.map((subscription) => Stream.fromSubscription(subscription)),
942 ),
943 }),
944 Layer.mock(MergebotQueue.MergebotQueue)({
945 configuration: {
946 origin: "https://mergebot.example.test",
947 repositories: ["owner/repository"],
948 },
949 read: () => Effect.succeed(null),
950 readPullRequest: options.mergebotDetail ?? (() => Effect.succeed(null)),
951 findPullRequest: options.mergebotFind ?? (() => Effect.succeed(null)),
952 streamChanges: Stream.unwrap(
953 Effect.gen(function* () {
954 const subscription = yield* PubSub.subscribe(mergebotChanges);
955 yield* Deferred.succeed(mergebotSubscribed, undefined);
956 return Stream.fromSubscription(subscription);
957 }),
958 ),
959 }),
960 Layer.succeed(ServerSettings.ServerSettingsService, serverSettings),
961 Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)),
962 Layer.succeed(Crypto.Crypto, testCrypto),
963 FileSystem.layerNoop({
964 exists: (path) => Effect.succeed(options.existingWorktreePaths?.includes(path) ?? false),
965 }),
966 );
967
968 return {
969 activation,
970 snapshots,
971 snapshotReadCount,
972 snapshotReads,
973 candidateReads,
974 commands,
975 branchCalls,
976 summaryCalls,
977 summaryRecovery,
978 providerDetailReads,
979 mergebotSubscribed,
980 publishMergebotChange: (number?: number) =>
981 PubSub.publish(mergebotChanges, {
982 repository: "owner/repository",
983 ...(number === undefined ? {} : { number }),
984 }),
985 invalidatedCwds,
986 closedTerminals,
987 archiveReads,
988 fullArchiveReadCount,
989 singleArchiveReads,
990 scriptRuns,
991 devServerStops,
992 previews,
993 publishEvent: (event: OrchestrationV2DomainEvent, commandId: CommandId | null = null) =>
994 PubSub.publish(domainEvents, { sequence: 2, commandId, event }),
995 updateSettings,
996 publishMerge: PubSub.publish(mergedPullRequests, {
997 projectId: PROJECT_ID,
998 repository: "owner/repository",
999 number: 42,
1000 mergedAt: NOW,
1001 }),
1002 layer: ThreadSettlementService.layer.pipe(Layer.provide(layerDependencies)),
1003 };
1004});
1005
1006const startHarness = Effect.fn("startThreadSettlementHarness")(function* (
1007 reactor: ThreadSettlementService.ThreadSettlementServiceV2["Service"],
1008 activation: Deferred.Deferred<void>,
1009 snapshotReads: Queue.Queue<number>,
1010) {
1011 yield* reactor.start();
1012 yield* Deferred.succeed(activation, undefined);
1013 yield* Queue.take(snapshotReads);
1014 yield* reactor.drain;
1015});
1016
1017describe("ThreadSettlementServiceV2 worker", () => {
1018 it.effect.each([true, false])(
1019 "honors inactivity PR retention %s for canonical work links without cached snapshots",
1020 (keepPullRequests) =>
1021 Effect.scoped(
1022 Effect.gen(function* () {
1023 yield* TestClock.setTime(Date.parse(NOW));
1024 const thread = makeThread("inactive-canonical-work-pr", { pullRequests: [link(1)] });
1025 const fixture = yield* makeHarness({
1026 snapshot: makeSnapshot([thread]),
1027 settings: {
1028 ...DEFAULT_SERVER_SETTINGS,
1029 sidebarAutoSettleAfterDays: 3,
1030 sidebarAutoSettleKeepPullRequests: keepPullRequests,
1031 },
1032 });
1033
1034 yield* Effect.gen(function* () {
1035 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1036 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1037 expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual(
1038 keepPullRequests ? [] : [thread.id],
1039 );
1040 }).pipe(Effect.provide(fixture.layer));
1041 }),
1042 ),
1043 );
1044
1045 it.effect("uses only Mergebot for configured linked and branch pull requests", () =>
1046 Effect.scoped(
1047 Effect.gen(function* () {
1048 yield* TestClock.setTime(Date.parse(NOW));
1049 const workProject = {
1050 ...makeProject(),
1051 repositoryIdentity: {
1052 canonicalKey: "github.com/owner/repository",
1053 locator: {
1054 source: "git-remote" as const,
1055 remoteName: "origin",
1056 remoteUrl: "https://github.com/owner/repository",
1057 },
1058 },
1059 };
1060 const legacy = makeThread("legacy-work-pr", {
1061 linkedPullRequest: {
1062 projectId: PROJECT_ID,
1063 repository: "owner/repository",
1064 number: 1,
1065 url: link(1).url,
1066 },
1067 });
1068 const linked = makeThread("linked-work-pr", {
1069 pullRequests: [
1070 {
1071 ...link(1),
1072 snapshot: {
1073 state: "open",
1074 title: "Old snapshot",
1075 headBranch: "feature",
1076 baseBranch: "main",
1077 isDraft: false,
1078 updatedAt: NOW,
1079 syncedAt: NOW,
1080 },
1081 },
1082 ],
1083 });
1084 const branch = makeThread("branch-work-pr", { branch: "feature" });
1085 const reused = makeThread("reused-work-branch", {
1086 linkedPullRequest: legacy.linkedPullRequest,
1087 branch: "reused-feature",
1088 });
1089 const fixture = yield* makeHarness({
1090 settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: null },
1091 snapshot: makeSnapshot([legacy, linked, branch, reused], [workProject]),
1092 mergebotDetail: ({ number }) =>
1093 Effect.succeed(mergebotDetail(number, { lifecycle: "merged", mergedAt: NOW })),
1094 mergebotFind: ({ headBranch }) =>
1095 Effect.succeed(
1096 mergebotDetail(1, {
1097 lifecycle: headBranch === "reused-feature" ? "open" : "merged",
1098 mergedAt: NOW,
1099 }),
1100 ),
1101 });
1102 yield* Effect.gen(function* () {
1103 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1104 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1105 expect(
1106 (yield* Ref.get(fixture.commands)).map((command) => command.threadId).toSorted(),
1107 ).toEqual([legacy.id, linked.id, branch.id].toSorted());
1108 expect(yield* Ref.get(fixture.branchCalls)).toEqual([]);
1109 expect(yield* Ref.get(fixture.summaryCalls)).toEqual([]);
1110 expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0);
1111 }).pipe(Effect.provide(fixture.layer));
1112 }),
1113 ),
1114 );
1115
1116 it.effect(
1117 "ignores cached work PR snapshots and unknown merge times while inactivity stays independent",
1118 () =>
1119 Effect.scoped(
1120 Effect.gen(function* () {
1121 yield* TestClock.setTime(Date.parse(NOW));
1122 const thread = makeThread("unknown-work-pr", {
1123 pullRequests: [
1124 {
1125 ...link(1),
1126 snapshot: {
1127 state: "merged",
1128 title: "Cached merge",
1129 headBranch: "feature",
1130 baseBranch: "main",
1131 isDraft: false,
1132 updatedAt: NOW,
1133 syncedAt: NOW,
1134 mergedAt: NOW,
1135 },
1136 },
1137 ],
1138 });
1139 for (const state of ["disconnected", "unknown-time", "inactive"] as const) {
1140 const fixture = yield* makeHarness({
1141 settings: {
1142 ...DEFAULT_SERVER_SETTINGS,
1143 sidebarAutoSettleAfterDays: state === "inactive" ? 1 : null,
1144 },
1145 snapshot: makeSnapshot([thread]),
1146 mergebotDetail: () =>
1147 Effect.succeed(
1148 state === "unknown-time"
1149 ? mergebotDetail(1, { lifecycle: "merged", updatedAt: NOW, mergedAt: null })
1150 : null,
1151 ),
1152 });
1153 yield* Effect.gen(function* () {
1154 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1155 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1156 expect((yield* Ref.get(fixture.commands)).length).toBe(state === "inactive" ? 1 : 0);
1157 expect(yield* Ref.get(fixture.branchCalls)).toEqual([]);
1158 expect(yield* Ref.get(fixture.summaryCalls)).toEqual([]);
1159 expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0);
1160 }).pipe(Effect.provide(fixture.layer));
1161 }
1162 }),
1163 ),
1164 );
1165
1166 it.effect("rechecks open resources after waiting for a merged pull request", () =>
1167 Effect.scoped(
1168 Effect.gen(function* () {
1169 yield* TestClock.setTime(Date.parse(NOW));
1170 const lookupStarted = yield* Deferred.make<void>();
1171 const lookupResult = yield* Deferred.make<GitManager.GitBranchPullRequest>();
1172 const thread = makeThread("open-during-lookup", { branch: "feature" });
1173 const fixture = yield* makeHarness({
1174 snapshot: makeSnapshot([thread]),
1175 settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: null },
1176 branchPullRequest: () =>
1177 Deferred.succeed(lookupStarted, undefined).pipe(
1178 Effect.andThen(Deferred.await(lookupResult)),
1179 ),
1180 });
1181
1182 yield* Effect.gen(function* () {
1183 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1184 const startup = yield* startHarness(
1185 service,
1186 fixture.activation,
1187 fixture.snapshotReads,
1188 ).pipe(Effect.forkScoped);
1189 yield* Deferred.await(lookupStarted);
1190 const tab = yield* fixture.previews.open({ threadId: thread.id });
1191 yield* Deferred.succeed(lookupResult, makeBranchPullRequest("merged"));
1192 yield* Fiber.join(startup);
1193 expect(yield* Ref.get(fixture.commands)).toEqual([]);
1194 expect((yield* fixture.previews.list({ threadId: thread.id })).sessions).toEqual([tab]);
1195 }).pipe(Effect.provide(fixture.layer));
1196 }),
1197 ),
1198 );
1199
1200 it.effect("keeps unattended chats with terminals, browser tabs, or commands active", () =>
1201 Effect.scoped(
1202 Effect.gen(function* () {
1203 yield* TestClock.setTime(Date.parse(NOW));
1204 const runningTerminal = makeThread("running-terminal");
1205 const idleTerminal = makeThread("idle-terminal");
1206 const exitedTerminal = makeThread("exited-terminal");
1207 const browser = makeThread("browser-tab");
1208 const background = makeThread("background-command", {
1209 pendingBackgroundTasks: [{ taskId: "command", kind: "command" }],
1210 });
1211 const quiet = makeThread("quiet-chat");
1212 const fixture = yield* makeHarness({
1213 snapshot: makeSnapshot([
1214 runningTerminal,
1215 idleTerminal,
1216 exitedTerminal,
1217 browser,
1218 background,
1219 quiet,
1220 ]),
1221 settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 1 },
1222 terminals: [
1223 terminal(runningTerminal.id),
1224 { ...terminal(idleTerminal.id), hasRunningSubprocess: false },
1225 { ...terminal(exitedTerminal.id), status: "exited", pid: null },
1226 ],
1227 });
1228 yield* fixture.previews.open({ threadId: browser.id });
1229
1230 yield* Effect.gen(function* () {
1231 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1232 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1233 expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([
1234 quiet.id,
1235 ]);
1236 expect((yield* fixture.previews.list({ threadId: browser.id })).sessions).toHaveLength(1);
1237 expect(yield* Queue.size(fixture.closedTerminals)).toBe(0);
1238 }).pipe(Effect.provide(fixture.layer));
1239 }),
1240 ),
1241 );
1242
1243 it.effect("settles a merged pull request stored only in the thread links", () =>
1244 Effect.scoped(
1245 Effect.gen(function* () {
1246 yield* TestClock.setTime(Date.parse(NOW));
1247 const thread = makeThread("merged-link", {
1248 pullRequests: [
1249 {
1250 host: "example.test",
1251 repository: "owner/repository",
1252 number: 42,
1253 url: "https://example.test/owner/repository/pull/42",
1254 source: "manual",
1255 linkedAt: "2026-08-20T00:00:00.000Z",
1256 snapshot: {
1257 state: "merged",
1258 title: "Pull request",
1259 headBranch: "feature",
1260 baseBranch: "main",
1261 isDraft: false,
1262 updatedAt: NOW,
1263 syncedAt: NOW,
1264 mergedAt: NOW,
1265 },
1266 stack: null,
1267 },
1268 ],
1269 });
1270 const fixture = yield* makeHarness({
1271 snapshot: makeSnapshot([thread]),
1272 settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: null },
1273 });
1274
1275 yield* Effect.gen(function* () {
1276 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1277 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1278 expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([
1279 thread.id,
1280 ]);
1281 }).pipe(Effect.provide(fixture.layer));
1282 }),
1283 ),
1284 );
1285
1286 it.effect("reevaluates a merged link immediately after its compact update event", () =>
1287 Effect.scoped(
1288 Effect.gen(function* () {
1289 yield* TestClock.setTime(Date.parse(NOW));
1290 const mergedLink = {
1291 ...link(42),
1292 host: "example.test",
1293 url: "https://example.test/owner/repository/pull/42",
1294 snapshot: {
1295 state: "merged" as const,
1296 title: "Pull request",
1297 headBranch: "feature",
1298 baseBranch: "main",
1299 isDraft: false,
1300 updatedAt: NOW,
1301 syncedAt: NOW,
1302 mergedAt: NOW,
1303 },
1304 };
1305 const thread = makeThread("compact-merged-link", { pullRequests: [mergedLink] });
1306 const settled = yield* Deferred.make<void>();
1307 const fixture = yield* makeHarness({
1308 snapshot: makeSnapshot([]),
1309 settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: null },
1310 onDispatch: () => Deferred.succeed(settled, undefined),
1311 });
1312
1313 yield* Effect.gen(function* () {
1314 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1315 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1316 yield* Ref.set(fixture.snapshots, makeSnapshot([thread]));
1317 yield* fixture.publishEvent({
1318 id: EventId.make("compact-merged-link"),
1319 type: "thread.pull-request-link-updated",
1320 threadId: thread.id,
1321 occurredAt: DateTime.makeUnsafe(NOW),
1322 payload: mergedLink,
1323 });
1324 yield* Deferred.await(settled);
1325 yield* service.drain;
1326 expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([
1327 thread.id,
1328 ]);
1329 }).pipe(Effect.provide(fixture.layer));
1330 }),
1331 ),
1332 );
1333
1334 it.effect("settles only the project opted in while environment settlement is disabled", () =>
1335 Effect.scoped(
1336 Effect.gen(function* () {
1337 yield* TestClock.setTime(Date.parse(NOW));
1338 const thread = makeThread("project-opt-in", {
1339 latestRunCompletedAt: DateTime.makeUnsafe("2026-08-25T00:00:00.000Z"),
1340 });
1341 const other = makeThread("environment-off", { projectId: ProjectId.make("other-project") });
1342 const fixture = yield* makeHarness({
1343 snapshot: makeSnapshot([thread, other]),
1344 settings: {
1345 ...DEFAULT_SERVER_SETTINGS,
1346 sidebarAutoSettleAfterDays: null,
1347 sidebarAutoSettleOnMerge: false,
1348 projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleAfterDays: 2 } },
1349 },
1350 });
1351 yield* Effect.gen(function* () {
1352 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1353 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1354 const commands = yield* Ref.get(fixture.commands);
1355 expect(commands.map((command) => command.threadId)).toEqual([thread.id]);
1356 }).pipe(Effect.provide(fixture.layer));
1357 }),
1358 ),
1359 );
1360
1361 it.effect("dispatches the last activity time with the v2 snapshot guard", () =>
1362 Effect.scoped(
1363 Effect.gen(function* () {
1364 yield* TestClock.setTime(Date.parse(NOW));
1365 const thread = makeThread("inactive-open-pr", {
1366 branch: "feature",
1367 latestRunCompletedAt: DateTime.makeUnsafe("2026-08-25T00:00:00.000Z"),
1368 });
1369 const fixture = yield* makeHarness({
1370 snapshot: makeSnapshot([thread]),
1371 settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 2 },
1372 branchPullRequest: () => Effect.succeed(makeBranchPullRequest("open")),
1373 });
1374 yield* Effect.gen(function* () {
1375 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1376 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1377 const commands = yield* Ref.get(fixture.commands);
1378 expect(commands).toHaveLength(1);
1379 assert.equal(commands[0]?.type, "thread.auto-settle");
1380 if (commands[0]?.type !== "thread.auto-settle") return;
1381 expect(commands[0]?.settledAt).toEqual(thread.latestRunCompletedAt);
1382 expect(commands[0]?.snapshotAt).toEqual(thread.updatedAt);
1383 }).pipe(Effect.provide(fixture.layer));
1384 }),
1385 ),
1386 );
1387
1388 it.effect("skips the branch recheck when a terminal link would settle nothing", () =>
1389 Effect.scoped(
1390 Effect.gen(function* () {
1391 yield* TestClock.setTime(Date.parse(NOW));
1392 const previous = {
1393 projectId: PROJECT_ID,
1394 repository: "owner/repository",
1395 number: 1,
1396 url: "https://example.test/owner/repository/pull/1",
1397 };
1398 const fixture = yield* makeHarness({
1399 snapshot: makeSnapshot(
1400 [
1401 makeThread("resumed-manual", {
1402 branch: "main",
1403 linkedPullRequest: previous,
1404 latestUserMessageAt: DateTime.makeUnsafe("2026-08-28T00:00:00.000Z"),
1405 }),
1406 ],
1407 [makeProject()],
1408 ),
1409 settings: {
1410 ...DEFAULT_SERVER_SETTINGS,
1411 sidebarAutoSettleAfterDays: null,
1412 sidebarAutoSettleOnMerge: true,
1413 },
1414 branchPullRequest: () => Effect.succeed(makeBranchPullRequest("open")),
1415 pullRequestSummary: (input) =>
1416 Effect.succeed({
1417 ...makePullRequestSummary({ ...input, state: "merged" }),
1418 mergedAt: "2026-08-27T00:00:00.000Z",
1419 }),
1420 });
1421 yield* Effect.gen(function* () {
1422 const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1423 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
1424 assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
1425 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
1426 assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []);
1427 }).pipe(Effect.provide(fixture.layer));
1428 }),
1429 ),
1430 );
1431
1432 it.effect("reevaluates immediately after a pull request merge", () =>
1433 Effect.scoped(
1434 Effect.gen(function* () {
1435 yield* TestClock.setTime(Date.parse(NOW));
1436 const periodicLookupStarted = yield* Deferred.make<void>();
1437 const releasePeriodicLookup = yield* Deferred.make<void>();
1438 const mergedThreadSettled = yield* Deferred.make<void>();
1439 const branchLookupCount = yield* Ref.make(0);
1440 const fixture = yield* makeHarness({
1441 snapshot: makeSnapshot([
1442 makeThread("merged-in-app", {
1443 latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"),
1444 linkedPullRequest: {
1445 projectId: PROJECT_ID,
1446 repository: "Owner/Repository",
1447 number: 42,
1448 url: "https://example.test/owner/repository/pull/42",
1449 },
1450 }),
1451 makeThread("slow-periodic-lookup", {
1452 branch: "another-feature",
1453 latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"),
1454 }),
1455 ]),
1456 branchPullRequest: () =>
1457 Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe(
1458 Effect.flatMap((count) =>
1459 count === 1
1460 ? Effect.succeed(makeBranchPullRequest("open"))
1461 : Deferred.succeed(periodicLookupStarted, undefined).pipe(
1462 Effect.andThen(Deferred.await(releasePeriodicLookup)),
1463 Effect.as(makeBranchPullRequest("open")),
1464 ),
1465 ),
1466 ),
1467 onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined),
1468 });
1469
1470 yield* Effect.gen(function* () {
1471 const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1472 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
1473 yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 });
1474 yield* Deferred.await(periodicLookupStarted);
1475
1476 yield* fixture.publishMerge;
1477 yield* Deferred.await(mergedThreadSettled);
1478
1479 assert.deepStrictEqual(
1480 (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
1481 [ThreadId.make("merged-in-app")],
1482 );
1483 yield* Deferred.succeed(releasePeriodicLookup, undefined);
1484 yield* reactor.drain;
1485 }).pipe(Effect.provide(fixture.layer));
1486 }),
1487 ),
1488 );
1489
1490 it.effect(
1491 "settles branch threads on a pull request merge without waiting for the next sweep",
1492 () =>
1493 Effect.scoped(
1494 Effect.gen(function* () {
1495 yield* TestClock.setTime(Date.parse(NOW));
1496 const state = yield* Ref.make<"open" | "merged">("open");
1497 const mergedThreadSettled = yield* Deferred.make<void>();
1498 const fixture = yield* makeHarness({
1499 snapshot: makeSnapshot([
1500 makeThread("branch-thread", {
1501 branch: "saved-feature",
1502 latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"),
1503 }),
1504 ]),
1505 branchPullRequest: () => Ref.get(state).pipe(Effect.map(makeBranchPullRequest)),
1506 onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined),
1507 });
1508
1509 yield* Effect.gen(function* () {
1510 const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1511 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
1512 assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
1513 assert.deepStrictEqual(yield* Ref.get(fixture.invalidatedCwds), []);
1514
1515 yield* Ref.set(state, "merged");
1516 yield* fixture.publishMerge;
1517 yield* Deferred.await(mergedThreadSettled);
1518
1519 assert.deepStrictEqual(
1520 (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
1521 [ThreadId.make("branch-thread")],
1522 );
1523 assert.deepStrictEqual(yield* Ref.get(fixture.invalidatedCwds), ["/workspace/project"]);
1524 yield* reactor.drain;
1525 }).pipe(Effect.provide(fixture.layer));
1526 }),
1527 ),
1528 );
1529
1530 it.effect("a merge does not settle threads linked to an unrelated pull request", () =>
1531 Effect.scoped(
1532 Effect.gen(function* () {
1533 yield* TestClock.setTime(Date.parse(NOW));
1534 const mergedThreadSettled = yield* Deferred.make<void>();
1535 const mergeLookupStarted = yield* Deferred.make<void>();
1536 const releaseMergeLookup = yield* Deferred.make<void>();
1537 const lookupCount = yield* Ref.make(0);
1538 const fixture = yield* makeHarness({
1539 snapshot: makeSnapshot([
1540 makeThread("merged-in-app", {
1541 latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"),
1542 linkedPullRequest: {
1543 projectId: PROJECT_ID,
1544 repository: "owner/repository",
1545 number: 42,
1546 url: "https://example.test/owner/repository/pull/42",
1547 },
1548 }),
1549 makeThread("unrelated-linked", {
1550 latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"),
1551 linkedPullRequest: {
1552 projectId: PROJECT_ID,
1553 repository: "owner/repository",
1554 number: 99,
1555 url: "https://example.test/owner/repository/pull/99",
1556 },
1557 }),
1558 ]),
1559 pullRequestSummary: (input) =>
1560 Ref.updateAndGet(lookupCount, (count) => count + 1).pipe(
1561 // The initial sweep looks up both linked threads; the merge
1562 // sweep only looks up the unrelated one, since the merged
1563 // thread settles from the event itself.
1564 Effect.tap((count) =>
1565 count === 3 ? Deferred.succeed(mergeLookupStarted, undefined) : Effect.void,
1566 ),
1567 Effect.tap((count) =>
1568 count === 3 ? Deferred.await(releaseMergeLookup) : Effect.void,
1569 ),
1570 Effect.map(() => makePullRequestSummary({ ...input, state: "open" })),
1571 ),
1572 onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined),
1573 });
1574
1575 yield* Effect.gen(function* () {
1576 const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1577 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
1578 assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
1579
1580 yield* fixture.publishMerge;
1581 yield* Deferred.await(mergeLookupStarted);
1582 yield* Deferred.await(mergedThreadSettled);
1583 yield* Deferred.succeed(releaseMergeLookup, undefined);
1584 yield* reactor.drain;
1585
1586 assert.deepStrictEqual(
1587 (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
1588 [ThreadId.make("merged-in-app")],
1589 );
1590 assert.deepStrictEqual(
1591 (yield* Ref.get(fixture.summaryCalls))
1592 .map((call) => call.number)
1593 .toSorted((left, right) => left - right),
1594 [42, 99, 99],
1595 );
1596 }).pipe(Effect.provide(fixture.layer));
1597 }),
1598 ),
1599 );
1600
1601 it.effect("looks up the branch pull request from a thread's live worktree", () =>
1602 Effect.scoped(
1603 Effect.gen(function* () {
1604 yield* TestClock.setTime(Date.parse(NOW));
1605 const fixture = yield* makeHarness({
1606 snapshot: makeSnapshot(
1607 [
1608 makeThread("live-worktree", {
1609 branch: "feature/live",
1610 worktreePath: "/workspace/project-root/.worktrees/live",
1611 }),
1612 makeThread("deleted-worktree", {
1613 branch: "feature/deleted",
1614 worktreePath: "/workspace/project-root/.worktrees/deleted",
1615 }),
1616 ],
1617 [makeProject(PROJECT_ID, "/workspace/project-root")],
1618 ),
1619 existingWorktreePaths: ["/workspace/project-root/.worktrees/live"],
1620 settings: {
1621 ...DEFAULT_SERVER_SETTINGS,
1622 sidebarAutoSettleAfterDays: null,
1623 sidebarAutoSettleOnMerge: true,
1624 },
1625 });
1626
1627 yield* Effect.gen(function* () {
1628 const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1629 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
1630
1631 assert.deepStrictEqual(
1632 new Set(yield* Ref.get(fixture.branchCalls)),
1633 new Set([
1634 { cwd: "/workspace/project-root/.worktrees/live", branch: "feature/live" },
1635 { cwd: "/workspace/project-root", branch: "feature/deleted" },
1636 ]),
1637 );
1638 }).pipe(Effect.provide(fixture.layer));
1639 }),
1640 ),
1641 );
1642});
1643
1644describe("ThreadSettlementServiceV2 development servers", () => {
1645 const settings = {
1646 ...DEFAULT_SERVER_SETTINGS,
1647 sidebarAutoSettleOnMerge: false,
1648 sidebarAutoSettleAfterDays: null,
1649 };
1650 const settled = (id: string, overrides: Partial<SettlementShell> = {}) =>
1651 makeThread(id, {
1652 settledOverride: "settled",
1653 settledAt: DateTime.makeUnsafe(NOW),
1654 ...overrides,
1655 });
1656 const start = Effect.fn("startDevServerCleanupHarness")(function* (
1657 fixture: Effect.Success<ReturnType<typeof makeHarness>>,
1658 ) {
1659 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1660 yield* service.start();
1661 yield* Deferred.succeed(fixture.activation, undefined);
1662 yield* Queue.take(fixture.archiveReads);
1663 yield* service.drain;
1664 return service;
1665 });
1666
1667 it.effect(
1668 "reconciles settled and archived servers on startup with auto-settlement disabled",
1669 () =>
1670 Effect.scoped(
1671 Effect.gen(function* () {
1672 yield* TestClock.setTime(Date.parse(NOW));
1673 const parked = settled("parked");
1674 const archived = makeThread("archived", { archivedAt: DateTime.makeUnsafe(NOW) });
1675 const fixture = yield* makeHarness({
1676 settings,
1677 snapshot: {
1678 ...makeSnapshot([parked, makeThread("active")]),
1679 archivedThreads: [archived],
1680 },
1681 managedThreadIds: [parked.id],
1682 });
1683 yield* start(fixture).pipe(Effect.provide(fixture.layer));
1684 assert.deepStrictEqual((yield* Ref.get(fixture.devServerStops)).toSorted(), [
1685 archived.id,
1686 parked.id,
1687 ]);
1688 assert.equal(yield* Queue.size(fixture.scriptRuns), 0);
1689 }),
1690 ),
1691 );
1692
1693 it.effect(
1694 "settling retains a managed workspace while stopping its separate development server",
1695 () =>
1696 Effect.scoped(
1697 Effect.gen(function* () {
1698 yield* TestClock.setTime(Date.parse(NOW));
1699 const thread = settled("managed", { worktreePath: null });
1700 const fixture = yield* makeHarness({
1701 settings,
1702 snapshot: makeSnapshot([]),
1703 currentThreads: [thread],
1704 managedThreadIds: [thread.id],
1705 });
1706 yield* Effect.gen(function* () {
1707 const service = yield* start(fixture);
1708 yield* Ref.set(fixture.snapshots, makeSnapshot([thread]));
1709 yield* fixture.publishEvent({
1710 type: "thread.settled",
1711 id: EventId.make("settled:managed"),
1712 threadId: thread.id,
1713 occurredAt: DateTime.makeUnsafe(NOW),
1714 payload: thread,
1715 });
1716 yield* Queue.take(fixture.archiveReads);
1717 yield* service.drain;
1718 assert.deepStrictEqual(yield* Ref.get(fixture.devServerStops), [thread.id]);
1719 assert.equal(yield* Queue.size(fixture.scriptRuns), 0);
1720 assert.equal(yield* Queue.size(fixture.closedTerminals), 1);
1721 }).pipe(Effect.provide(fixture.layer));
1722 }),
1723 ),
1724 );
1725
1726 it.effect("retries a failed shutdown on the next minute without blocking healthy threads", () =>
1727 Effect.scoped(
1728 Effect.gen(function* () {
1729 yield* TestClock.setTime(Date.parse(NOW));
1730 const failed = settled("failed-stop");
1731 const healthy = settled("healthy-stop");
1732 const attempts = yield* Ref.make(0);
1733 const fixture = yield* makeHarness({
1734 settings,
1735 snapshot: makeSnapshot([failed, healthy]),
1736 onStopUnused: (threadId) =>
1737 threadId !== failed.id
1738 ? Effect.void
1739 : Ref.updateAndGet(attempts, (count) => count + 1).pipe(
1740 Effect.flatMap((count) =>
1741 count === 1
1742 ? Effect.fail(new DevServerError({ reason: "status" }))
1743 : Effect.void,
1744 ),
1745 ),
1746 });
1747 yield* Effect.gen(function* () {
1748 const service = yield* start(fixture);
1749 assert.deepStrictEqual((yield* Ref.get(fixture.devServerStops)).toSorted(), [
1750 failed.id,
1751 healthy.id,
1752 ]);
1753 yield* TestClock.adjust("1 minute");
1754 yield* Queue.take(fixture.archiveReads);
1755 yield* service.drain;
1756 assert.equal(yield* Ref.get(attempts), 2);
1757 assert.equal((yield* Ref.get(fixture.devServerStops)).length, 4);
1758 assert.equal(yield* Queue.size(fixture.scriptRuns), 0);
1759 }).pipe(Effect.provide(fixture.layer));
1760 }),
1761 ),
1762 );
1763
1764 it.effect("committed archival stops an unsettled thread's server", () =>
1765 Effect.scoped(
1766 Effect.gen(function* () {
1767 yield* TestClock.setTime(Date.parse(NOW));
1768 const thread = makeThread("archiving", { archivedAt: DateTime.makeUnsafe(NOW) });
1769 const fixture = yield* makeHarness({ settings, snapshot: makeSnapshot([]) });
1770 yield* Effect.gen(function* () {
1771 const service = yield* start(fixture);
1772 yield* Ref.set(fixture.snapshots, {
1773 ...makeSnapshot([]),
1774 archivedThreads: [thread],
1775 });
1776 yield* fixture.publishEvent({
1777 type: "thread.archived",
1778 id: EventId.make("archived:archiving"),
1779 threadId: thread.id,
1780 occurredAt: DateTime.makeUnsafe(NOW),
1781 payload: thread,
1782 });
1783 yield* Queue.take(fixture.archiveReads);
1784 yield* service.drain;
1785 assert.deepStrictEqual(yield* Ref.get(fixture.devServerStops), [thread.id]);
1786 assert.equal(yield* Queue.size(fixture.scriptRuns), 0);
1787 }).pipe(Effect.provide(fixture.layer));
1788 }),
1789 ),
1790 );
1791
1792 it.effect("a settlement event already overtaken by revival does not stop its server", () =>
1793 Effect.scoped(
1794 Effect.gen(function* () {
1795 yield* TestClock.setTime(Date.parse(NOW));
1796 const old = settled("revived");
1797 const active = makeThread("revived", { settledOverride: "active", status: "running" });
1798 const fixture = yield* makeHarness({
1799 settings,
1800 snapshot: makeSnapshot([]),
1801 currentThreads: [active],
1802 });
1803 yield* Effect.gen(function* () {
1804 const service = yield* start(fixture);
1805 yield* Ref.set(fixture.snapshots, makeSnapshot([active]));
1806 yield* fixture.publishEvent({
1807 type: "thread.settled",
1808 id: EventId.make("settled:revived"),
1809 threadId: old.id,
1810 occurredAt: DateTime.makeUnsafe(NOW),
1811 payload: old,
1812 });
1813 yield* Queue.take(fixture.archiveReads);
1814 yield* service.drain;
1815 assert.deepStrictEqual(yield* Ref.get(fixture.devServerStops), []);
1816 assert.equal(yield* Queue.size(fixture.closedTerminals), 0);
1817 }).pipe(Effect.provide(fixture.layer));
1818 }),
1819 ),
1820 );
1821});
1822
1823describe("ThreadSettlementServiceV2 terminals", () => {
1824 const appThread = (
1825 id: string,
1826 settledOverride: OrchestrationV2AppThread["settledOverride"],
1827 worktreePath: string | null = null,
1828 ): OrchestrationV2AppThread => {
1829 const threadId = ThreadId.make(id);
1830 return {
1831 createdBy: "user",
1832 creationSource: "web",
1833 id: threadId,
1834 projectId: PROJECT_ID,
1835 title: id,
1836 providerInstanceId: ProviderInstanceId.make("codex"),
1837 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
1838 runtimeMode: "full-access",
1839 interactionMode: "default",
1840 branch: null,
1841 worktreePath,
1842 activeProviderThreadId: null,
1843 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
1844 forkedFrom: null,
1845 createdAt: DateTime.makeUnsafe(NOW),
1846 updatedAt: DateTime.makeUnsafe(NOW),
1847 archivedAt: null,
1848 settledOverride,
1849 settledAt: settledOverride === "settled" ? DateTime.makeUnsafe(NOW) : null,
1850 lastVisitedAt: null,
1851 deletedAt: null,
1852 };
1853 };
1854 const settledEvent = (thread: OrchestrationV2AppThread): OrchestrationV2DomainEvent => ({
1855 type: "thread.settled",
1856 id: EventId.make(`event:settled:${thread.id}`),
1857 threadId: thread.id,
1858 occurredAt: DateTime.makeUnsafe(NOW),
1859 payload: thread,
1860 });
1861
1862 it.effect("closes all of the thread's terminals and browser tabs when it settles", () =>
1863 Effect.scoped(
1864 Effect.gen(function* () {
1865 const thread = appThread("settled-thread", "settled");
1866 const fixture = yield* makeHarness({
1867 snapshot: makeSnapshot([]),
1868 currentThreads: [thread],
1869 });
1870 yield* fixture.previews.open({ threadId: thread.id });
1871 yield* fixture.previews.open({ threadId: thread.id, runtime: "server" });
1872 const peerId = ThreadId.make("other-chat");
1873 const peerTab = yield* fixture.previews.open({ threadId: peerId });
1874 const previewEvents = yield* fixture.previews.subscribeEvents;
1875
1876 yield* Effect.gen(function* () {
1877 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1878 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1879 yield* fixture.publishEvent(settledEvent(thread));
1880 assert.deepStrictEqual(yield* Queue.take(fixture.closedTerminals), {
1881 threadId: thread.id,
1882 });
1883 expect((yield* PubSub.take(previewEvents)).type).toBe("closed");
1884 expect((yield* PubSub.take(previewEvents)).type).toBe("closed");
1885 expect((yield* fixture.previews.list({ threadId: thread.id })).sessions).toEqual([]);
1886 expect((yield* fixture.previews.list({ threadId: peerId })).sessions).toEqual([peerTab]);
1887 const active = appThread("settled-thread", "active");
1888 yield* fixture.publishEvent({
1889 type: "thread.unsettled",
1890 id: EventId.make("event:unsettled:managed"),
1891 threadId: active.id,
1892 occurredAt: DateTime.makeUnsafe(NOW),
1893 payload: active,
1894 });
1895 }).pipe(Effect.provide(fixture.layer));
1896 }),
1897 ),
1898 );
1899
1900 it.effect("preserves resources opened during an automatic settlement", () =>
1901 Effect.scoped(
1902 Effect.gen(function* () {
1903 const thread = appThread("automatic-thread", "settled", "/worktrees/automatic");
1904 const marker = appThread("marker-thread", "settled", "/worktrees/marker");
1905 const fixture = yield* makeHarness({
1906 snapshot: makeSnapshot([]),
1907 currentThreads: [thread, marker],
1908 terminals: [terminal(thread.id)],
1909 existingWorktreePaths: ["/worktrees/automatic", "/worktrees/marker"],
1910 });
1911 const tab = yield* fixture.previews.open({ threadId: thread.id });
1912
1913 yield* Effect.gen(function* () {
1914 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1915 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1916 yield* fixture.publishEvent(
1917 settledEvent(thread),
1918 CommandId.make("server:auto-settle:automatic-thread:1"),
1919 );
1920 yield* fixture.publishEvent(settledEvent(marker));
1921 expect((yield* Queue.take(fixture.scriptRuns)).threadId).toBe(marker.id);
1922 expect(yield* Queue.take(fixture.closedTerminals)).toEqual({ threadId: marker.id });
1923 expect(yield* Queue.size(fixture.closedTerminals)).toBe(0);
1924 expect((yield* fixture.previews.list({ threadId: thread.id })).sessions).toEqual([tab]);
1925 expect(yield* Ref.get(fixture.commands)).toMatchObject([
1926 { type: "thread.unsettle", threadId: thread.id, snapshotSequence: 2 },
1927 ]);
1928 }).pipe(Effect.provide(fixture.layer));
1929 }),
1930 ),
1931 );
1932
1933 it.effect("ignores a stale settle event after the chat was revived and settled again", () =>
1934 Effect.scoped(
1935 Effect.gen(function* () {
1936 const previous = appThread("resettled-thread", "settled");
1937 const current = { ...previous, settledAt: DateTime.makeUnsafe(Date.parse(NOW) + 1) };
1938 const marker = appThread("marker-thread", "settled");
1939 const fixture = yield* makeHarness({
1940 snapshot: makeSnapshot([]),
1941 currentThreads: [current, marker],
1942 });
1943 const tab = yield* fixture.previews.open({ threadId: current.id });
1944
1945 yield* Effect.gen(function* () {
1946 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1947 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1948 yield* fixture.publishEvent(settledEvent(previous));
1949 yield* fixture.publishEvent(settledEvent(marker));
1950 expect(yield* Queue.take(fixture.closedTerminals)).toEqual({ threadId: marker.id });
1951 expect((yield* fixture.previews.list({ threadId: current.id })).sessions).toEqual([tab]);
1952 }).pipe(Effect.provide(fixture.layer));
1953 }),
1954 ),
1955 );
1956
1957 it.effect("keeps the terminals of a thread re-engaged before the event ran", () =>
1958 Effect.scoped(
1959 Effect.gen(function* () {
1960 const settled = appThread("reengaged-thread", "settled");
1961 const reengaged = appThread("reengaged-thread", "active");
1962 const marker = appThread("marker-thread", "settled");
1963 const fixture = yield* makeHarness({
1964 snapshot: makeSnapshot([]),
1965 currentThreads: [reengaged, marker],
1966 });
1967
1968 yield* Effect.gen(function* () {
1969 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1970 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1971 yield* fixture.publishEvent(settledEvent(settled));
1972 // Events run in order, so the first close belongs to the later
1973 // event only if the re-engaged thread was skipped.
1974 yield* fixture.publishEvent(settledEvent(marker));
1975 assert.deepStrictEqual(yield* Queue.take(fixture.closedTerminals), {
1976 threadId: marker.id,
1977 });
1978 }).pipe(Effect.provide(fixture.layer));
1979 }),
1980 ),
1981 );
1982
1983 it.effect("runs the settle script in the thread's own worktree", () =>
1984 Effect.scoped(
1985 Effect.gen(function* () {
1986 const shared = appThread("shared-checkout-thread", "settled");
1987 const missing = appThread("removed-worktree-thread", "settled", "/worktrees/removed");
1988 const worktree = appThread("worktree-thread", "settled", "/worktrees/thread");
1989 const fixture = yield* makeHarness({
1990 snapshot: makeSnapshot([]),
1991 currentThreads: [shared, missing, worktree],
1992 existingWorktreePaths: ["/worktrees/thread"],
1993 });
1994
1995 yield* Effect.gen(function* () {
1996 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1997 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1998 // Events run in order, so the first run belongs to the last event
1999 // only if the shared checkout and the removed worktree were skipped.
2000 yield* fixture.publishEvent(settledEvent(shared));
2001 yield* fixture.publishEvent(settledEvent(missing));
2002 yield* fixture.publishEvent(settledEvent(worktree));
2003 const run = yield* Queue.take(fixture.scriptRuns);
2004 assert.strictEqual(run.threadId, worktree.id);
2005 assert.strictEqual(run.worktreePath, "/worktrees/thread");
2006 assert.strictEqual(run.trigger, "settle");
2007 }).pipe(Effect.provide(fixture.layer));
2008 }),
2009 ),
2010 );
2011
2012 it.effect(
2013 "keeps browser tabs and skips the settle script when revived while terminals close",
2014 () =>
2015 Effect.scoped(
2016 Effect.gen(function* () {
2017 const settled = appThread("reengaging-thread", "settled", "/worktrees/reengaging");
2018 const marker = appThread("marker-thread", "settled", "/worktrees/marker");
2019 const currentThreads = [settled, marker];
2020 const fixture = yield* makeHarness({
2021 snapshot: makeSnapshot([]),
2022 currentThreads,
2023 existingWorktreePaths: ["/worktrees/reengaging", "/worktrees/marker"],
2024 onCloseTerminals: () =>
2025 Effect.sync(() => {
2026 currentThreads[0] = { ...settled, settledOverride: "active" };
2027 }),
2028 });
2029 const tab = yield* fixture.previews.open({ threadId: settled.id });
2030
2031 yield* Effect.gen(function* () {
2032 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
2033 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
2034 yield* fixture.publishEvent(settledEvent(settled));
2035 yield* fixture.publishEvent(settledEvent(marker));
2036 assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, marker.id);
2037 expect((yield* fixture.previews.list({ threadId: settled.id })).sessions).toEqual([
2038 tab,
2039 ]);
2040 }).pipe(Effect.provide(fixture.layer));
2041 }),
2042 ),
2043 );
2044
2045 it.effect("runs the settle script once when a settlement is re-emitted", () =>
2046 Effect.scoped(
2047 Effect.gen(function* () {
2048 const repeated = appThread("repeated-thread", "settled", "/worktrees/repeated");
2049 const marker = appThread("marker-thread", "settled", "/worktrees/marker");
2050 const fixture = yield* makeHarness({
2051 snapshot: makeSnapshot([]),
2052 currentThreads: [repeated, marker],
2053 existingWorktreePaths: ["/worktrees/repeated", "/worktrees/marker"],
2054 });
2055
2056 yield* Effect.gen(function* () {
2057 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
2058 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
2059 // Settling an already settled thread emits the same settlement again.
2060 yield* fixture.publishEvent(settledEvent(repeated));
2061 yield* fixture.publishEvent(settledEvent(repeated));
2062 yield* fixture.publishEvent(settledEvent(marker));
2063 assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, repeated.id);
2064 assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, marker.id);
2065 }).pipe(Effect.provide(fixture.layer));
2066 }),
2067 ),
2068 );
2069
2070 it.effect("retries the settle script on a re-emitted settlement after a failed start", () =>
2071 Effect.scoped(
2072 Effect.gen(function* () {
2073 const thread = appThread("failed-start-thread", "settled", "/worktrees/failed-start");
2074 const starts = yield* Ref.make(0);
2075 const fixture = yield* makeHarness({
2076 snapshot: makeSnapshot([]),
2077 currentThreads: [thread],
2078 existingWorktreePaths: ["/worktrees/failed-start"],
2079 onScriptRun: () =>
2080 Ref.updateAndGet(starts, (count) => count + 1).pipe(
2081 Effect.flatMap((count) =>
2082 count === 1 ? Effect.die(new Error("terminal failed to open")) : Effect.void,
2083 ),
2084 ),
2085 });
2086
2087 yield* Effect.gen(function* () {
2088 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
2089 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
2090 yield* fixture.publishEvent(settledEvent(thread));
2091 yield* fixture.publishEvent(settledEvent(thread));
2092 assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, thread.id);
2093 assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, thread.id);
2094 }).pipe(Effect.provide(fixture.layer));
2095 }),
2096 ),
2097 );
2098});
2099
2100describe("ThreadSettlementServiceV2 single-thread sweeps", () => {
2101 it.effect("a finished run reads only its own thread's settlement candidate", () =>
2102 Effect.scoped(
2103 Effect.gen(function* () {
2104 yield* TestClock.setTime(Date.parse(NOW));
2105 const finished = makeThread("finished-run");
2106 const other = makeThread("other-thread");
2107 const fixture = yield* makeHarness({
2108 snapshot: makeSnapshot([finished, other]),
2109 settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 3 },
2110 });
2111
2112 yield* Effect.gen(function* () {
2113 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
2114 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
2115 yield* Ref.set(fixture.candidateReads, []);
2116 yield* fixture.publishEvent({
2117 type: "provider-session.detached",
2118 id: EventId.make("event:detached"),
2119 threadId: finished.id,
2120 occurredAt: DateTime.makeUnsafe(NOW),
2121 payload: {
2122 providerSessionId: ProviderSessionId.make("provider-session:finished"),
2123 detachedAt: DateTime.makeUnsafe(NOW),
2124 },
2125 });
2126 yield* Queue.take(fixture.snapshotReads);
2127 yield* service.drain;
2128 assert.deepStrictEqual(yield* Ref.get(fixture.candidateReads), [finished.id]);
2129 }).pipe(Effect.provide(fixture.layer));
2130 }),
2131 ),
2132 );
2133});
2134
2135describe("isSnoozed", () => {
2136 const snoozed = { snoozedAt: at(-2 * DAY_MS), snoozedUntil: at(DAY_MS) };
2137 it("wakes for completed work after the snooze, but not an interrupted run", () => {
2138 expect(ThreadSettlementService.isSnoozed(shell(snoozed), NOW_MS)).toBe(true);
2139 expect(
2140 ThreadSettlementService.isSnoozed(
2141 shell({ ...snoozed, status: "interrupted", latestRunCompletedAt: at(-DAY_MS) }),
2142 NOW_MS,
2143 ),
2144 ).toBe(true);
2145 expect(
2146 ThreadSettlementService.isSnoozed(
2147 shell({ ...snoozed, status: "completed", latestRunCompletedAt: at(-DAY_MS) }),
2148 NOW_MS,
2149 ),
2150 ).toBe(false);
2151 expect(
2152 ThreadSettlementService.isSnoozed(shell({ ...snoozed, snoozedUntil: at(-1) }), NOW_MS),
2153 ).toBe(false);
2154 });
2155});
2156
2157describe("settled thread archival", () => {
2158 const settings = {
2159 ...DEFAULT_SERVER_SETTINGS,
2160 sidebarAutoSettleOnMerge: false,
2161 sidebarAutoSettleAfterDays: null,
2162 };
2163
2164 const start = Effect.fn("startArchiveHarness")(function* (
2165 fixture: Effect.Success<ReturnType<typeof makeHarness>>,
2166 ) {
2167 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
2168 yield* service.start();
2169 yield* Deferred.succeed(fixture.activation, undefined);
2170 yield* Queue.take(fixture.archiveReads);
2171 yield* service.drain;
2172 });
2173
2174 it.effect(
2175 "archives from a live Mergebot merge update without a minute timer or provider lookup",
2176 () =>
2177 Effect.scoped(
2178 Effect.gen(function* () {
2179 yield* TestClock.setTime(Date.parse(NOW));
2180 const thread = makeThread("live-merge", {
2181 settledOverride: "settled",
2182 settledAt: DateTime.makeUnsafe(NOW),
2183 pullRequests: [link(1)],
2184 });
2185 const detail = yield* Ref.make<MergebotPullRequestDetails | null>(null);
2186 const archived = yield* Deferred.make<void>();
2187 const fixture = yield* makeHarness({
2188 settings,
2189 snapshot: makeSnapshot([thread]),
2190 mergebotDetail: () => Ref.get(detail),
2191 onDispatch: (command) =>
2192 command.type === "thread.archive"
2193 ? Deferred.succeed(archived, undefined).pipe(Effect.asVoid)
2194 : Effect.void,
2195 });
2196 yield* Effect.gen(function* () {
2197 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
2198 yield* start(fixture);
2199 yield* Deferred.await(fixture.mergebotSubscribed);
2200 expect(yield* Ref.get(fixture.commands)).toEqual([]);
2201 yield* Ref.set(detail, mergebotDetail(1, { lifecycle: "merged", mergedAt: NOW }));
2202 yield* fixture.publishMergebotChange(1);
2203 yield* TestClock.adjust("100 millis");
2204 yield* Deferred.await(archived);
2205 yield* service.drain;
2206 expect((yield* Ref.get(fixture.commands)).map((command) => command.type)).toEqual([
2207 "thread.archive",
2208 ]);
2209 expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0);
2210 }).pipe(Effect.provide(fixture.layer));
2211 }),
2212 ),
2213 );
2214
2215 it.effect("Saturday archival stays available while Mergebot is disconnected", () =>
2216 Effect.scoped(
2217 Effect.gen(function* () {
2218 yield* TestClock.setTime(Date.parse(NOW));
2219 const boundary = ThreadSettlementService.lastSaturdayArchiveBoundary(Date.parse(NOW));
2220 const fixture = yield* makeHarness({
2221 settings,
2222 snapshot: makeSnapshot([
2223 makeThread("offline-saturday", {
2224 settledOverride: "settled",
2225 settledAt: DateTime.makeUnsafe(boundary),
2226 pullRequests: [link(1)],
2227 }),
2228 ]),
2229 mergebotDetail: () => Effect.succeed(null),
2230 });
2231 yield* start(fixture).pipe(Effect.provide(fixture.layer));
2232 expect((yield* Ref.get(fixture.commands)).map((command) => command.type)).toEqual([
2233 "thread.archive",
2234 ]);
2235 expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0);
2236 }),
2237 ),
2238 );
2239
2240 it("uses Saturday at nine in the server timezone, including a DST week", () => {
2241 const local = DateTime.zoneMakeLocal();
2242 const saturday = DateTime.makeZonedUnsafe(
2243 { year: 2026, month: 11, day: 7, hour: 9 },
2244 { timeZone: local, adjustForTimeZone: true },
2245 );
2246 const friday = DateTime.subtract(saturday, { days: 1 });
2247 expect(
2248 ThreadSettlementService.lastSaturdayArchiveBoundary(DateTime.toEpochMillis(saturday)),
2249 ).toBe(DateTime.toEpochMillis(saturday));
2250 expect(
2251 ThreadSettlementService.lastSaturdayArchiveBoundary(DateTime.toEpochMillis(friday)),
2252 ).toBe(DateTime.toEpochMillis(DateTime.subtract(saturday, { weeks: 1 })));
2253 expect(
2254 ThreadSettlementService.lastSaturdayArchiveBoundary(
2255 DateTime.toEpochMillis(DateTime.subtract(saturday, { milliseconds: 1 })),
2256 ),
2257 ).toBe(DateTime.toEpochMillis(DateTime.subtract(saturday, { weeks: 1 })));
2258 });
2259
2260 it.effect(
2261 "catches up after restart, excluding fresh settlements and busy threads with auto-settlement disabled",
2262 () =>
2263 Effect.scoped(
2264 Effect.gen(function* () {
2265 const nowMs = Date.parse(NOW);
2266 yield* TestClock.setTime(nowMs);
2267 const boundary = ThreadSettlementService.lastSaturdayArchiveBoundary(nowMs);
2268 const fixture = yield* makeHarness({
2269 settings,
2270 snapshot: makeSnapshot([
2271 makeThread("old", {
2272 settledOverride: "settled",
2273 settledAt: DateTime.makeUnsafe(boundary),
2274 }),
2275 makeThread("fresh", {
2276 settledOverride: "settled",
2277 settledAt: DateTime.makeUnsafe(boundary + 1),
2278 }),
2279 makeThread("busy", {
2280 settledOverride: "settled",
2281 settledAt: DateTime.makeUnsafe(boundary),
2282 activeRunId: RunId.make("run"),
2283 status: "running",
2284 }),
2285 makeThread("queued", {
2286 settledOverride: "settled",
2287 settledAt: DateTime.makeUnsafe(boundary),
2288 latestUserMessageAt: DateTime.makeUnsafe(nowMs),
2289 }),
2290 makeThread("active", { settledOverride: "active" }),
2291 makeThread("backdated", {
2292 settledOverride: "settled",
2293 settledAt: DateTime.makeUnsafe(boundary - DAY_MS),
2294 }),
2295 ]),
2296 settlementTimes: new Map([[ThreadId.make("backdated"), DateTime.makeUnsafe(nowMs)]]),
2297 });
2298 yield* start(fixture).pipe(Effect.provide(fixture.layer));
2299 assert.deepStrictEqual(
2300 (yield* Ref.get(fixture.commands)).map((command) => [command.type, command.threadId]),
2301 [["thread.archive", ThreadId.make("old")]],
2302 );
2303 }),
2304 ),
2305 );
2306
2307 it.effect(
2308 "archives a settled managed thread only when every visible PR merged, reading shared PRs once",
2309 () =>
2310 Effect.scoped(
2311 Effect.gen(function* () {
2312 yield* TestClock.setTime(Date.parse(NOW));
2313 const reads = yield* Ref.make<ReadonlyArray<number>>([]);
2314 const threads = ["merged-a", "merged-b", "stack-open"].map((id) =>
2315 makeThread(id, {
2316 settledOverride: "settled",
2317 settledAt: DateTime.makeUnsafe(NOW),
2318 pullRequests: id === "stack-open" ? [link(1), link(2)] : [link(1)],
2319 }),
2320 );
2321 const fixture = yield* makeHarness({
2322 settings,
2323 snapshot: makeSnapshot(threads),
2324 managedThreadIds: threads.map((thread) => thread.id),
2325 mergebotDetail: (reference) =>
2326 Ref.update(reads, (values) => [...values, reference.number]).pipe(
2327 Effect.as(
2328 mergebotDetail(reference.number, {
2329 lifecycle: reference.number === 1 ? "merged" : "open",
2330 }),
2331 ),
2332 ),
2333 });
2334 yield* start(fixture).pipe(Effect.provide(fixture.layer));
2335 assert.deepStrictEqual(
2336 (yield* Ref.get(fixture.commands)).map((command) => command.threadId).toSorted(),
2337 [ThreadId.make("merged-a"), ThreadId.make("merged-b")],
2338 );
2339 assert.deepStrictEqual((yield* Ref.get(reads)).toSorted(), [1, 2]);
2340 }),
2341 ),
2342 );
2343
2344 it.effect("a managed lookup failure leaves healthy archival and auto-settlement running", () =>
2345 Effect.scoped(
2346 Effect.gen(function* () {
2347 const nowMs = Date.parse(NOW);
2348 yield* TestClock.setTime(nowMs);
2349 const boundary = ThreadSettlementService.lastSaturdayArchiveBoundary(nowMs);
2350 const failed = makeThread("failed-lookup", {
2351 settledOverride: "settled",
2352 settledAt: DateTime.makeUnsafe(boundary),
2353 pullRequests: [{ ...link(1), repository: "unconfigured/repository" }],
2354 });
2355 const fixture = yield* makeHarness({
2356 settings: { ...settings, sidebarAutoSettleAfterDays: 1 },
2357 snapshot: makeSnapshot([
2358 failed,
2359 makeThread("plain-archive", {
2360 settledOverride: "settled",
2361 settledAt: DateTime.makeUnsafe(boundary),
2362 }),
2363 makeThread("inactive", {
2364 latestUserMessageAt: DateTime.makeUnsafe(nowMs - 30 * DAY_MS),
2365 }),
2366 ]),
2367 managedLookup: () => Effect.fail(new ManagedWorkspaceError({ reason: "workspace" })),
2368 });
2369 yield* start(fixture).pipe(Effect.provide(fixture.layer));
2370 assert.deepStrictEqual(
2371 (yield* Ref.get(fixture.commands)).map((command) => [command.type, command.threadId]),
2372 [
2373 ["thread.archive", ThreadId.make("plain-archive")],
2374 ["thread.auto-settle", ThreadId.make("inactive")],
2375 ],
2376 );
2377 }),
2378 ),
2379 );
2380
2381 it.effect("observes configured work PRs from the repository's main checkout", () =>
2382 Effect.scoped(
2383 Effect.gen(function* () {
2384 yield* TestClock.setTime(Date.parse(NOW));
2385 const thread = makeThread("root-work-pr", {
2386 settledOverride: "settled",
2387 settledAt: DateTime.makeUnsafe(NOW),
2388 worktreePath: null,
2389 pullRequests: [link(1)],
2390 });
2391 const fixture = yield* makeHarness({
2392 settings,
2393 snapshot: makeSnapshot([thread]),
2394 managedLookup: () => Effect.fail(new ManagedWorkspaceError({ reason: "workspace" })),
2395 mergebotDetail: () =>
2396 Effect.succeed(mergebotDetail(1, { lifecycle: "merged", mergedAt: NOW })),
2397 });
2398 yield* start(fixture).pipe(Effect.provide(fixture.layer));
2399 assert.deepStrictEqual(
2400 (yield* Ref.get(fixture.commands)).map((command) => command.type),
2401 ["thread.archive"],
2402 );
2403 }),
2404 ),
2405 );
2406
2407 it.effect(
2408 "settling after merge archives immediately and skips the managed workspace settle script",
2409 () =>
2410 Effect.scoped(
2411 Effect.gen(function* () {
2412 yield* TestClock.setTime(Date.parse(NOW));
2413 const thread = {
2414 ...makeThread("settle-after-merge", {
2415 settledOverride: "settled",
2416 settledAt: DateTime.makeUnsafe(NOW),
2417 worktreePath: "/workspace/managed",
2418 pullRequests: [link(1)],
2419 }),
2420 lastVisitedAt: null,
2421 };
2422 const archived = yield* Deferred.make<void>();
2423 const fixture = yield* makeHarness({
2424 settings,
2425 snapshot: makeSnapshot([]),
2426 currentThreads: [thread],
2427 managedThreadIds: [thread.id],
2428 existingWorktreePaths: ["/workspace/managed"],
2429 mergebotDetail: () =>
2430 Effect.succeed(mergebotDetail(1, { lifecycle: "merged", mergedAt: NOW })),
2431 onDispatch: (command) =>
2432 command.type === "thread.archive"
2433 ? Deferred.succeed(archived, undefined).pipe(Effect.asVoid)
2434 : Effect.void,
2435 });
2436 yield* Effect.gen(function* () {
2437 yield* start(fixture);
2438 yield* Ref.set(fixture.snapshots, makeSnapshot([thread]));
2439 yield* fixture.publishEvent({
2440 type: "thread.settled",
2441 id: EventId.make("settled-after-merge"),
2442 threadId: thread.id,
2443 occurredAt: DateTime.makeUnsafe(NOW),
2444 payload: thread,
2445 });
2446 yield* Deferred.await(archived);
2447 assert.equal(yield* Ref.get(fixture.fullArchiveReadCount), 1);
2448 assert.deepStrictEqual(yield* Ref.get(fixture.singleArchiveReads), [thread.id]);
2449 assert.equal(yield* Queue.size(fixture.scriptRuns), 0);
2450 assert.deepStrictEqual(
2451 (yield* Ref.get(fixture.commands)).map((command) => command.type),
2452 ["thread.archive"],
2453 );
2454 }).pipe(Effect.provide(fixture.layer));
2455 }),
2456 ),
2457 );
2458
2459 it.effect(
2460 "wakes failed current-head CI through a notification command without dispatching a message or starting a run",
2461 () =>
2462 Effect.scoped(
2463 Effect.gen(function* () {
2464 yield* TestClock.setTime(Date.parse(NOW));
2465 const thread = makeThread("ci-failed", {
2466 settledOverride: "settled",
2467 settledAt: DateTime.makeUnsafe(NOW),
2468 pullRequests: [link(1)],
2469 });
2470 const fixture = yield* makeHarness({
2471 settings,
2472 snapshot: makeSnapshot([thread]),
2473 managedThreadIds: [thread.id],
2474 mergebotDetail: () =>
2475 Effect.succeed(
2476 mergebotDetail(1, {
2477 checks: [failedCheck()],
2478 }),
2479 ),
2480 });
2481 yield* start(fixture).pipe(Effect.provide(fixture.layer));
2482 const commands = yield* Ref.get(fixture.commands);
2483 assert.equal(commands.length, 1);
2484 assert.equal(commands[0]?.type, "thread.ci-failed");
2485 if (commands[0]?.type !== "thread.ci-failed") return;
2486 assert.equal(commands[0].url, "https://mergebot.example.test/owner/repository/pull/1");
2487 assert.ok(commands[0].itemId.includes(":current-head:"));
2488 }),
2489 ),
2490 );
2491
2492 it.effect("records each failed check attempt once and wakes again for a same-head rerun", () =>
2493 Effect.scoped(
2494 Effect.gen(function* () {
2495 yield* TestClock.setTime(Date.parse(NOW));
2496 const thread = makeThread("rerun-ci", {
2497 settledOverride: "settled",
2498 settledAt: DateTime.makeUnsafe(NOW),
2499 pullRequests: [link(1)],
2500 });
2501 const original = failedCheck({ completedAt: "2026-08-28T11:00:00.000Z" });
2502 const first = yield* makeHarness({
2503 settings,
2504 snapshot: makeSnapshot([thread]),
2505 managedThreadIds: [thread.id],
2506 mergebotDetail: () => Effect.succeed(mergebotDetail(1, { checks: [original] })),
2507 });
2508 yield* start(first).pipe(Effect.provide(first.layer));
2509 const previous = (yield* Ref.get(first.commands))[0];
2510 assert.ok(previous?.type === "thread.ci-failed");
2511 for (const changed of [
2512 null,
2513 { completedAt: "2026-08-28T11:30:00.000Z" },
2514 { id: "rerun-check-2" },
2515 ]) {
2516 const fixture = yield* makeHarness({
2517 settings,
2518 snapshot: makeSnapshot([thread]),
2519 managedThreadIds: [thread.id],
2520 previousItemIds: new Set([previous.itemId]),
2521 mergebotDetail: () =>
2522 Effect.succeed(mergebotDetail(1, { checks: [{ ...original, ...changed }] })),
2523 });
2524 yield* start(fixture).pipe(Effect.provide(fixture.layer));
2525 const commands = yield* Ref.get(fixture.commands);
2526 assert.equal(commands.length, changed === null ? 0 : 1);
2527 if (commands[0]?.type === "thread.ci-failed")
2528 assert.notEqual(commands[0].itemId, previous.itemId);
2529 }
2530 }),
2531 ),
2532 );
2533
2534 it.effect("ignores stale snapshots and failures already recorded for the current head", () =>
2535 Effect.scoped(
2536 Effect.gen(function* () {
2537 yield* TestClock.setTime(Date.parse(NOW));
2538 const thread = makeThread("known-ci", {
2539 settledOverride: "settled",
2540 settledAt: DateTime.makeUnsafe(NOW),
2541 pullRequests: [link(1)],
2542 });
2543 for (const previousCiNotice of [false, true]) {
2544 const fixture = yield* makeHarness({
2545 settings,
2546 snapshot: makeSnapshot([thread]),
2547 previousCiNotice,
2548 mergebotDetail: () =>
2549 Effect.succeed(
2550 previousCiNotice ? mergebotDetail(1, { checks: [failedCheck()] }) : null,
2551 ),
2552 });
2553 yield* start(fixture).pipe(Effect.provide(fixture.layer));
2554 expect(yield* Ref.get(fixture.commands)).toEqual([]);
2555 expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0);
2556 }
2557 }),
2558 ),
2559 );
2560});