import { assert, describe, expect, it } from "@effect/vitest";import * as DateTime from "effect/DateTime";import { DEFAULT_SERVER_SETTINGS, ProjectId, ProviderInstanceId, ThreadId, type OrchestrationProjectShell, type OrchestrationV2Command, type OrchestrationV2ShellSnapshot, type OrchestrationV2ThreadShell, type PullRequestSummary, type ServerSettings, type ServerSettingsPatch,} from "@t3tools/contracts";import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";import * as Crypto from "effect/Crypto";import * as Deferred from "effect/Deferred";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as PubSub from "effect/PubSub";import * as Queue from "effect/Queue";import * as Ref from "effect/Ref";import * as Stream from "effect/Stream";import { TestClock } from "effect/testing"; import { GitManager } from "../git/GitManager.ts";import { PullRequestService, type PullRequestMergeEvent,} from "../pullRequest/PullRequestService.ts";import { ServerActivation } from "../serverActivation.ts";import { ServerSettingsService } from "../serverSettings.ts";import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";import { OrchestratorV2, type OrchestratorV2Shape } from "./Orchestrator.ts";import { ProjectionStoreV2 } from "./ProjectionStore.ts";import * as ThreadSettlementService from "./ThreadSettlementService.ts"; import { isAutoSettlementCandidate, QUEUED_TURN_START_GRACE_MS, resolveAutoSettlementAt, threadHasQueuedTurnStart,} from "./ThreadSettlementService.ts"; const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z");const DAY_MS = 24 * 60 * 60 * 1_000; function at(offsetMs: number): DateTime.Utc { return DateTime.makeUnsafe(NOW_MS + offsetMs);} function shell(overrides: Partial<OrchestrationV2ThreadShell> = {}): OrchestrationV2ThreadShell { return { id: ThreadId.make("thread-1"), projectId: ProjectId.make("project-1"), title: "Thread", providerInstanceId: ProviderInstanceId.make("codex"), modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", worktreePath: null, activeProviderThreadId: null, lineage: { rootThreadId: ThreadId.make("thread-1"), parentThreadId: null, relationshipToParent: null, }, forkedFrom: null, createdBy: "user", creationSource: "web", activeRunId: null, latestVisibleMessage: null, hasActionableProposedPlan: false, itemCount: 0, visibleItemCount: 0, lastVisitedAt: null, deletedAt: null, branch: null, linkedPullRequest: null, status: "idle", activityRunStatus: null, pendingRuntimeRequest: null, pendingBackgroundTasks: [], latestRunId: null, latestRunRequestedAt: null, latestRunStartedAt: null, latestRunCompletedAt: null, latestUserMessageAt: null, createdAt: at(-30 * DAY_MS), updatedAt: at(-10 * DAY_MS), archivedAt: null, settledOverride: null, settledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: null, ...overrides, };} describe("isAutoSettlementCandidate", () => { it("excludes overridden, pinned, blocked, and working threads", () => { expect(isAutoSettlementCandidate(shell(), NOW_MS)).toBe(true); expect(isAutoSettlementCandidate(shell({ archivedAt: at(-1) }), NOW_MS)).toBe(false); expect(isAutoSettlementCandidate(shell({ settledOverride: "settled" }), NOW_MS)).toBe(false); expect(isAutoSettlementCandidate(shell({ settledOverride: "active" }), NOW_MS)).toBe(false); expect(isAutoSettlementCandidate(shell({ pinnedAt: at(-1) }), NOW_MS)).toBe(false); expect(isAutoSettlementCandidate(shell({ activityRunStatus: "running" }), NOW_MS)).toBe(false); expect( isAutoSettlementCandidate( shell({ pendingRuntimeRequest: { kind: "approval" } as never, }), NOW_MS, ), ).toBe(false); expect( isAutoSettlementCandidate( shell({ pendingBackgroundTasks: [{ label: "task" }] as never }), NOW_MS, ), ).toBe(false); }); it("keeps snoozed threads parked until they wake early on error or completion", () => { const snoozed = shell({ snoozedUntil: at(60 * 60 * 1_000), snoozedAt: at(-60 * 60 * 1_000), }); expect(isAutoSettlementCandidate(snoozed, NOW_MS)).toBe(false); expect( isAutoSettlementCandidate( shell({ ...snoozed, status: "failed", latestRunCompletedAt: at(-30 * 60 * 1_000) }), NOW_MS, ), ).toBe(true); expect( isAutoSettlementCandidate( shell({ ...snoozed, latestRunCompletedAt: at(-30 * 60 * 1_000) }), NOW_MS, ), ).toBe(true); expect( isAutoSettlementCandidate( shell({ ...snoozed, status: "failed", latestRunCompletedAt: at(-2 * 60 * 60 * 1_000) }), NOW_MS, ), ).toBe(false); expect( isAutoSettlementCandidate( shell({ ...snoozed, status: "failed", latestRunCompletedAt: snoozed.snoozedAt }), NOW_MS, ), ).toBe(false); expect( isAutoSettlementCandidate( shell({ ...snoozed, status: "failed", latestRunCompletedAt: null }), NOW_MS, ), ).toBe(false); // Expired snooze is no longer a park. expect(isAutoSettlementCandidate(shell({ ...snoozed, snoozedUntil: at(-1) }), NOW_MS)).toBe( true, ); });}); describe("threadHasQueuedTurnStart", () => { it("holds a fresh unadopted user message inside the grace window only", () => { const fresh = shell({ latestUserMessageAt: at(-1_000) }); expect(threadHasQueuedTurnStart(fresh, NOW_MS)).toBe(true); // Adoption stamps the run with the message time, clearing the hold. expect( threadHasQueuedTurnStart( shell({ latestUserMessageAt: at(-1_000), latestRunId: "run-1" as never, latestRunRequestedAt: at(-500), }), NOW_MS, ), ).toBe(false); // Outside the grace window the stale message no longer blocks. expect( threadHasQueuedTurnStart( shell({ latestUserMessageAt: at(-QUEUED_TURN_START_GRACE_MS - 1) }), NOW_MS, ), ).toBe(false); // Client clocks ahead of the server must not extend the hold. expect( threadHasQueuedTurnStart( shell({ latestUserMessageAt: at(QUEUED_TURN_START_GRACE_MS + 1) }), NOW_MS, ), ).toBe(false); // A failed start clears the hold immediately. expect( threadHasQueuedTurnStart( shell({ latestUserMessageAt: at(-1_000), status: "failed" }), NOW_MS, ), ).toBe(false); });}); describe("resolveAutoSettlementAt", () => { it("uses the latest activity time when the inactivity window elapses", () => { const idle = shell({ latestUserMessageAt: at(-4 * DAY_MS), latestRunRequestedAt: at(-4 * DAY_MS), latestRunStartedAt: at(-4 * DAY_MS), latestRunCompletedAt: at(-3 * DAY_MS), }); const input = { thread: idle, pullRequest: null, nowMs: NOW_MS, autoSettleAfterDays: 2, autoSettleOnMerge: true, }; expect(resolveAutoSettlementAt(input)).toEqual(at(-3 * DAY_MS)); expect(resolveAutoSettlementAt({ ...input, autoSettleAfterDays: 5 })).toBeNull(); expect(resolveAutoSettlementAt({ ...input, autoSettleAfterDays: null })).toBeNull(); expect(resolveAutoSettlementAt({ ...input, thread: shell() })).toBeNull(); }); it("settles on merge only after the user's last action and preserves the activity time", () => { const thread = shell({ latestUserMessageAt: at(-2 * 60 * 60 * 1_000), latestRunCompletedAt: at(-90 * 60 * 1_000), }); const input = { thread, pullRequest: { state: "merged" as const, updatedAt: DateTime.formatIso(at(-60 * 60 * 1_000)), }, nowMs: NOW_MS, autoSettleAfterDays: null, autoSettleOnMerge: true, }; expect(resolveAutoSettlementAt(input)).toEqual(at(-90 * 60 * 1_000)); expect(resolveAutoSettlementAt({ ...input, autoSettleOnMerge: false })).toBeNull(); expect( resolveAutoSettlementAt({ ...input, pullRequest: { state: "merged", updatedAt: DateTime.formatIso(at(-3 * 60 * 60 * 1_000)) }, }), ).toBeNull(); // A thread without messages still has a stable timestamp when its PR closes. expect( resolveAutoSettlementAt({ ...input, thread: shell(), pullRequest: { state: "closed", updatedAt: DateTime.formatIso(at(-1)) }, autoSettleOnMerge: false, }), ).toEqual(shell().createdAt); }); it("settles inactive threads even when their pull request remains open", () => { const input = { thread: shell({ latestUserMessageAt: at(-30 * DAY_MS) }), pullRequest: { state: "open" as const, updatedAt: DateTime.formatIso(at(0)) }, nowMs: NOW_MS, autoSettleAfterDays: 2, autoSettleOnMerge: true, }; expect(resolveAutoSettlementAt(input)).toEqual(at(-30 * DAY_MS)); expect(resolveAutoSettlementAt({ ...input, autoSettleAfterDays: null })).toBeNull(); expect( resolveAutoSettlementAt({ ...input, thread: shell({ latestUserMessageAt: at(-DAY_MS) }), }), ).toBeNull(); });}); const NOW = "2026-08-28T12:00:00.000Z";const PROJECT_ID = ProjectId.make("settlement-project"); type AutoSettleCommand = Extract<OrchestrationV2Command, { readonly type: "thread.auto-settle" }>; const testCrypto = Crypto.make({ randomBytes: (size) => new Uint8Array(size).fill(1), digest: (_algorithm, data) => Effect.succeed(data),}); function makeProject( id: ProjectId = PROJECT_ID, workspaceRoot = "/workspace/project",): OrchestrationProjectShell { return { id, title: `Project ${id}`, workspaceRoot, defaultModelSelection: null, scripts: [], createdAt: "2026-08-01T00:00:00.000Z", updatedAt: NOW, };} function makeThread( id: string, overrides: Partial<OrchestrationV2ThreadShell> = {},): OrchestrationV2ThreadShell { return shell({ id: ThreadId.make(id), projectId: PROJECT_ID, title: id, createdAt: DateTime.makeUnsafe("2026-08-01T00:00:00.000Z"), updatedAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"), latestUserMessageAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"), ...overrides, });} function makeSnapshot( threads: ReadonlyArray<OrchestrationV2ThreadShell>, projects: ReadonlyArray<OrchestrationProjectShell> = [makeProject()],): OrchestrationV2ShellSnapshot { return { schemaVersion: 1, snapshotSequence: 1, projects, threads, archivedThreads: [], };} function makePullRequestSummary(input: { readonly projectId: ProjectId; readonly repository: string; readonly number: number; readonly state: "open" | "closed" | "merged"; readonly updatedAt?: string;}): PullRequestSummary { return { provider: "github", projectId: input.projectId, repository: input.repository, number: input.number, title: "Pull request", url: `https://example.test/${input.repository}/pull/${input.number}`, state: input.state, headBranch: "feature", baseBranch: "main", updatedAt: input.updatedAt ?? NOW, };} interface HarnessOptions { readonly snapshot: OrchestrationV2ShellSnapshot; readonly settings?: ServerSettings; readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; readonly pullRequestSummary?: PullRequestService["Service"]["summary"]; readonly existingWorktreePaths?: ReadonlyArray<string>; readonly onDispatch?: (command: AutoSettleCommand) => Effect.Effect<void>;} const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: HarnessOptions) { const activation = yield* Deferred.make<void>(); const snapshots = yield* Ref.make(options.snapshot); const snapshotReadCount = yield* Ref.make(0); const snapshotReads = yield* Queue.unbounded<number>(); const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); const settingsChanges = yield* PubSub.unbounded<ServerSettings>(); const mergedPullRequests = yield* PubSub.unbounded<PullRequestMergeEvent>(); const commands = yield* Ref.make<ReadonlyArray<AutoSettleCommand>>([]); const branchCalls = yield* Ref.make< ReadonlyArray<{ readonly cwd: string; readonly branch: string }> >([]); const summaryCalls = yield* Ref.make< ReadonlyArray<{ readonly projectId: ProjectId; readonly repository: string; readonly number: number; }> >([]); const summaryRecovery = yield* Ref.make<ReadonlyArray<boolean | undefined>>([]); const invalidatedCwds = yield* Ref.make<ReadonlyArray<string>>([]); const updateSettings = (patch: ServerSettingsPatch) => Effect.gen(function* () { const next = applyServerSettingsPatch(yield* Ref.get(settings), patch); yield* Ref.set(settings, next); yield* PubSub.publish(settingsChanges, next); return next; }); const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) => Ref.update(branchCalls, (calls) => [...calls, input]).pipe( Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), ); const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) => Effect.gen(function* () { yield* Ref.update(summaryCalls, (calls) => [...calls, input]); yield* Ref.update(summaryRecovery, (values) => [ ...values, readOptions?.recoverTransientFailure, ]); return yield* ( options.pullRequestSummary?.(input, readOptions) ?? Effect.succeed( makePullRequestSummary({ ...input, state: "open", }), ) ); }); const dispatch: OrchestratorV2Shape["dispatch"] = (command) => { if (command.type !== "thread.auto-settle") { return Effect.die(new Error(`Unexpected command: ${command.type}`)); } return Ref.update(commands, (recorded) => [...recorded, command]).pipe( Effect.andThen(options.onDispatch?.(command) ?? Effect.void), Effect.as({ sequence: 1, storedEvents: [] }), ); }; const serverSettings = ServerSettingsService.of({ start: Effect.void, ready: Effect.void, getSettings: Ref.get(settings), updateSettings, streamChanges: Stream.fromPubSub(settingsChanges), subscribeChanges: PubSub.subscribe(settingsChanges).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), ), }); const dependencies = Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({ getProjectShellsWithoutEnrichment: () => Ref.get(snapshots).pipe(Effect.map((snapshot) => snapshot.projects)), }), Layer.mock(ProjectionStoreV2)({ getSettlementCandidates: () => Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe( Effect.tap((count) => Queue.offer(snapshotReads, count)), Effect.andThen(Ref.get(snapshots)), Effect.map((snapshot) => snapshot.threads), ), }), Layer.mock(OrchestratorV2)({ dispatch, }), Layer.mock(GitManager)({ branchPullRequest, invalidateStatus: (cwd) => Ref.update(invalidatedCwds, (cwds) => [...cwds, cwd]), }), Layer.mock(PullRequestService)({ summary: pullRequestSummary, subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), ), }), Layer.succeed(ServerSettingsService, serverSettings), Layer.succeed(ServerActivation, Deferred.await(activation)), Layer.succeed(Crypto.Crypto, testCrypto), FileSystem.layerNoop({ exists: (path) => Effect.succeed(options.existingWorktreePaths?.includes(path) ?? false), }), ); return { activation, snapshots, snapshotReadCount, snapshotReads, commands, branchCalls, summaryCalls, summaryRecovery, invalidatedCwds, updateSettings, publishMerge: PubSub.publish(mergedPullRequests, { projectId: PROJECT_ID, repository: "owner/repository", number: 42, mergedAt: NOW, }), layer: ThreadSettlementService.layer.pipe(Layer.provide(dependencies)), };}); const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( reactor: ThreadSettlementService.ThreadSettlementServiceV2["Service"], activation: Deferred.Deferred<void>, snapshotReads: Queue.Queue<number>,) { yield* reactor.start(); yield* Deferred.succeed(activation, undefined);Lines 1–500 of 739. Load more
import { assert, describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import { DEFAULT_SERVER_SETTINGS, ProjectId, ProviderInstanceId, ThreadId, type OrchestrationProjectShell, type OrchestrationV2Command, type OrchestrationV2ShellSnapshot, type OrchestrationV2ThreadShell, type PullRequestSummary, type ServerSettings, type ServerSettingsPatch, } from "@t3tools/contracts"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { GitManager } from "../git/GitManager.ts"; import { PullRequestService, type PullRequestMergeEvent, } from "../pullRequest/PullRequestService.ts"; import { ServerActivation } from "../serverActivation.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { OrchestratorV2, type OrchestratorV2Shape } from "./Orchestrator.ts"; import { ProjectionStoreV2 } from "./ProjectionStore.ts"; import * as ThreadSettlementService from "./ThreadSettlementService.ts"; import { isAutoSettlementCandidate, QUEUED_TURN_START_GRACE_MS, resolveAutoSettlementAt, threadHasQueuedTurnStart, } from "./ThreadSettlementService.ts"; const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); const DAY_MS = 24 * 60 * 60 * 1_000; function at(offsetMs: number): DateTime.Utc { return DateTime.makeUnsafe(NOW_MS + offsetMs); } function shell(overrides: Partial<OrchestrationV2ThreadShell> = {}): OrchestrationV2ThreadShell { return { id: ThreadId.make("thread-1"), projectId: ProjectId.make("project-1"), title: "Thread", providerInstanceId: ProviderInstanceId.make("codex"), modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", worktreePath: null, activeProviderThreadId: null, lineage: { rootThreadId: ThreadId.make("thread-1"), parentThreadId: null, relationshipToParent: null, }, forkedFrom: null, createdBy: "user", creationSource: "web", activeRunId: null, latestVisibleMessage: null, hasActionableProposedPlan: false, itemCount: 0, visibleItemCount: 0, lastVisitedAt: null, deletedAt: null, branch: null, linkedPullRequest: null, status: "idle", activityRunStatus: null, pendingRuntimeRequest: null, pendingBackgroundTasks: [], latestRunId: null, latestRunRequestedAt: null, latestRunStartedAt: null, latestRunCompletedAt: null, latestUserMessageAt: null, createdAt: at(-30 * DAY_MS), updatedAt: at(-10 * DAY_MS), archivedAt: null, settledOverride: null, settledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: null, ...overrides, }; } describe("isAutoSettlementCandidate", () => { it("excludes overridden, pinned, blocked, and working threads", () => { expect(isAutoSettlementCandidate(shell(), NOW_MS)).toBe(true); expect(isAutoSettlementCandidate(shell({ archivedAt: at(-1) }), NOW_MS)).toBe(false); expect(isAutoSettlementCandidate(shell({ settledOverride: "settled" }), NOW_MS)).toBe(false); expect(isAutoSettlementCandidate(shell({ settledOverride: "active" }), NOW_MS)).toBe(false); expect(isAutoSettlementCandidate(shell({ pinnedAt: at(-1) }), NOW_MS)).toBe(false); expect(isAutoSettlementCandidate(shell({ activityRunStatus: "running" }), NOW_MS)).toBe(false); expect( isAutoSettlementCandidate( shell({ pendingRuntimeRequest: { kind: "approval" } as never, }), NOW_MS, ), ).toBe(false); expect( isAutoSettlementCandidate( shell({ pendingBackgroundTasks: [{ label: "task" }] as never }), NOW_MS, ), ).toBe(false); }); it("keeps snoozed threads parked until they wake early on error or completion", () => { const snoozed = shell({ snoozedUntil: at(60 * 60 * 1_000), snoozedAt: at(-60 * 60 * 1_000), }); expect(isAutoSettlementCandidate(snoozed, NOW_MS)).toBe(false); expect( isAutoSettlementCandidate( shell({ ...snoozed, status: "failed", latestRunCompletedAt: at(-30 * 60 * 1_000) }), NOW_MS, ), ).toBe(true); expect( isAutoSettlementCandidate( shell({ ...snoozed, latestRunCompletedAt: at(-30 * 60 * 1_000) }), NOW_MS, ), ).toBe(true); expect( isAutoSettlementCandidate( shell({ ...snoozed, status: "failed", latestRunCompletedAt: at(-2 * 60 * 60 * 1_000) }), NOW_MS, ), ).toBe(false); expect( isAutoSettlementCandidate( shell({ ...snoozed, status: "failed", latestRunCompletedAt: snoozed.snoozedAt }), NOW_MS, ), ).toBe(false); expect( isAutoSettlementCandidate( shell({ ...snoozed, status: "failed", latestRunCompletedAt: null }), NOW_MS, ), ).toBe(false); // Expired snooze is no longer a park. expect(isAutoSettlementCandidate(shell({ ...snoozed, snoozedUntil: at(-1) }), NOW_MS)).toBe( true, ); }); }); describe("threadHasQueuedTurnStart", () => { it("holds a fresh unadopted user message inside the grace window only", () => { const fresh = shell({ latestUserMessageAt: at(-1_000) }); expect(threadHasQueuedTurnStart(fresh, NOW_MS)).toBe(true); // Adoption stamps the run with the message time, clearing the hold. expect( threadHasQueuedTurnStart( shell({ latestUserMessageAt: at(-1_000), latestRunId: "run-1" as never, latestRunRequestedAt: at(-500), }), NOW_MS, ), ).toBe(false); // Outside the grace window the stale message no longer blocks. expect( threadHasQueuedTurnStart( shell({ latestUserMessageAt: at(-QUEUED_TURN_START_GRACE_MS - 1) }), NOW_MS, ), ).toBe(false); // Client clocks ahead of the server must not extend the hold. expect( threadHasQueuedTurnStart( shell({ latestUserMessageAt: at(QUEUED_TURN_START_GRACE_MS + 1) }), NOW_MS, ), ).toBe(false); // A failed start clears the hold immediately. expect( threadHasQueuedTurnStart( shell({ latestUserMessageAt: at(-1_000), status: "failed" }), NOW_MS, ), ).toBe(false); }); }); describe("resolveAutoSettlementAt", () => { it("uses the latest activity time when the inactivity window elapses", () => { const idle = shell({ latestUserMessageAt: at(-4 * DAY_MS), latestRunRequestedAt: at(-4 * DAY_MS), latestRunStartedAt: at(-4 * DAY_MS), latestRunCompletedAt: at(-3 * DAY_MS), }); const input = { thread: idle, pullRequest: null, nowMs: NOW_MS, autoSettleAfterDays: 2, autoSettleOnMerge: true, }; expect(resolveAutoSettlementAt(input)).toEqual(at(-3 * DAY_MS)); expect(resolveAutoSettlementAt({ ...input, autoSettleAfterDays: 5 })).toBeNull(); expect(resolveAutoSettlementAt({ ...input, autoSettleAfterDays: null })).toBeNull(); expect(resolveAutoSettlementAt({ ...input, thread: shell() })).toBeNull(); }); it("settles on merge only after the user's last action and preserves the activity time", () => { const thread = shell({ latestUserMessageAt: at(-2 * 60 * 60 * 1_000), latestRunCompletedAt: at(-90 * 60 * 1_000), }); const input = { thread, pullRequest: { state: "merged" as const, updatedAt: DateTime.formatIso(at(-60 * 60 * 1_000)), }, nowMs: NOW_MS, autoSettleAfterDays: null, autoSettleOnMerge: true, }; expect(resolveAutoSettlementAt(input)).toEqual(at(-90 * 60 * 1_000)); expect(resolveAutoSettlementAt({ ...input, autoSettleOnMerge: false })).toBeNull(); expect( resolveAutoSettlementAt({ ...input, pullRequest: { state: "merged", updatedAt: DateTime.formatIso(at(-3 * 60 * 60 * 1_000)) }, }), ).toBeNull(); // A thread without messages still has a stable timestamp when its PR closes. expect( resolveAutoSettlementAt({ ...input, thread: shell(), pullRequest: { state: "closed", updatedAt: DateTime.formatIso(at(-1)) }, autoSettleOnMerge: false, }), ).toEqual(shell().createdAt); }); it("settles inactive threads even when their pull request remains open", () => { const input = { thread: shell({ latestUserMessageAt: at(-30 * DAY_MS) }), pullRequest: { state: "open" as const, updatedAt: DateTime.formatIso(at(0)) }, nowMs: NOW_MS, autoSettleAfterDays: 2, autoSettleOnMerge: true, }; expect(resolveAutoSettlementAt(input)).toEqual(at(-30 * DAY_MS)); expect(resolveAutoSettlementAt({ ...input, autoSettleAfterDays: null })).toBeNull(); expect( resolveAutoSettlementAt({ ...input, thread: shell({ latestUserMessageAt: at(-DAY_MS) }), }), ).toBeNull(); }); }); const NOW = "2026-08-28T12:00:00.000Z"; const PROJECT_ID = ProjectId.make("settlement-project"); type AutoSettleCommand = Extract<OrchestrationV2Command, { readonly type: "thread.auto-settle" }>; const testCrypto = Crypto.make({ randomBytes: (size) => new Uint8Array(size).fill(1), digest: (_algorithm, data) => Effect.succeed(data), }); function makeProject( id: ProjectId = PROJECT_ID, workspaceRoot = "/workspace/project", ): OrchestrationProjectShell { return { id, title: `Project ${id}`, workspaceRoot, defaultModelSelection: null, scripts: [], createdAt: "2026-08-01T00:00:00.000Z", updatedAt: NOW, }; } function makeThread( id: string, overrides: Partial<OrchestrationV2ThreadShell> = {}, ): OrchestrationV2ThreadShell { return shell({ id: ThreadId.make(id), projectId: PROJECT_ID, title: id, createdAt: DateTime.makeUnsafe("2026-08-01T00:00:00.000Z"), updatedAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"), latestUserMessageAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"), ...overrides, }); } function makeSnapshot( threads: ReadonlyArray<OrchestrationV2ThreadShell>, projects: ReadonlyArray<OrchestrationProjectShell> = [makeProject()], ): OrchestrationV2ShellSnapshot { return { schemaVersion: 1, snapshotSequence: 1, projects, threads, archivedThreads: [], }; } function makePullRequestSummary(input: { readonly projectId: ProjectId; readonly repository: string; readonly number: number; readonly state: "open" | "closed" | "merged"; readonly updatedAt?: string; }): PullRequestSummary { return { provider: "github", projectId: input.projectId, repository: input.repository, number: input.number, title: "Pull request", url: `https://example.test/${input.repository}/pull/${input.number}`, state: input.state, headBranch: "feature", baseBranch: "main", updatedAt: input.updatedAt ?? NOW, }; } interface HarnessOptions { readonly snapshot: OrchestrationV2ShellSnapshot; readonly settings?: ServerSettings; readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; readonly pullRequestSummary?: PullRequestService["Service"]["summary"]; readonly existingWorktreePaths?: ReadonlyArray<string>; readonly onDispatch?: (command: AutoSettleCommand) => Effect.Effect<void>; } const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: HarnessOptions) { const activation = yield* Deferred.make<void>(); const snapshots = yield* Ref.make(options.snapshot); const snapshotReadCount = yield* Ref.make(0); const snapshotReads = yield* Queue.unbounded<number>(); const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); const settingsChanges = yield* PubSub.unbounded<ServerSettings>(); const mergedPullRequests = yield* PubSub.unbounded<PullRequestMergeEvent>(); const commands = yield* Ref.make<ReadonlyArray<AutoSettleCommand>>([]); const branchCalls = yield* Ref.make< ReadonlyArray<{ readonly cwd: string; readonly branch: string }> >([]); const summaryCalls = yield* Ref.make< ReadonlyArray<{ readonly projectId: ProjectId; readonly repository: string; readonly number: number; }> >([]); const summaryRecovery = yield* Ref.make<ReadonlyArray<boolean | undefined>>([]); const invalidatedCwds = yield* Ref.make<ReadonlyArray<string>>([]); const updateSettings = (patch: ServerSettingsPatch) => Effect.gen(function* () { const next = applyServerSettingsPatch(yield* Ref.get(settings), patch); yield* Ref.set(settings, next); yield* PubSub.publish(settingsChanges, next); return next; }); const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) => Ref.update(branchCalls, (calls) => [...calls, input]).pipe( Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), ); const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) => Effect.gen(function* () { yield* Ref.update(summaryCalls, (calls) => [...calls, input]); yield* Ref.update(summaryRecovery, (values) => [ ...values, readOptions?.recoverTransientFailure, ]); return yield* ( options.pullRequestSummary?.(input, readOptions) ?? Effect.succeed( makePullRequestSummary({ ...input, state: "open", }), ) ); }); const dispatch: OrchestratorV2Shape["dispatch"] = (command) => { if (command.type !== "thread.auto-settle") { return Effect.die(new Error(`Unexpected command: ${command.type}`)); } return Ref.update(commands, (recorded) => [...recorded, command]).pipe( Effect.andThen(options.onDispatch?.(command) ?? Effect.void), Effect.as({ sequence: 1, storedEvents: [] }), ); }; const serverSettings = ServerSettingsService.of({ start: Effect.void, ready: Effect.void, getSettings: Ref.get(settings), updateSettings, streamChanges: Stream.fromPubSub(settingsChanges), subscribeChanges: PubSub.subscribe(settingsChanges).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), ), }); const dependencies = Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({ getProjectShellsWithoutEnrichment: () => Ref.get(snapshots).pipe(Effect.map((snapshot) => snapshot.projects)), }), Layer.mock(ProjectionStoreV2)({ getSettlementCandidates: () => Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe( Effect.tap((count) => Queue.offer(snapshotReads, count)), Effect.andThen(Ref.get(snapshots)), Effect.map((snapshot) => snapshot.threads), ), }), Layer.mock(OrchestratorV2)({ dispatch, }), Layer.mock(GitManager)({ branchPullRequest, invalidateStatus: (cwd) => Ref.update(invalidatedCwds, (cwds) => [...cwds, cwd]), }), Layer.mock(PullRequestService)({ summary: pullRequestSummary, subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), ), }), Layer.succeed(ServerSettingsService, serverSettings), Layer.succeed(ServerActivation, Deferred.await(activation)), Layer.succeed(Crypto.Crypto, testCrypto), FileSystem.layerNoop({ exists: (path) => Effect.succeed(options.existingWorktreePaths?.includes(path) ?? false), }), ); return { activation, snapshots, snapshotReadCount, snapshotReads, commands, branchCalls, summaryCalls, summaryRecovery, invalidatedCwds, updateSettings, publishMerge: PubSub.publish(mergedPullRequests, { projectId: PROJECT_ID, repository: "owner/repository", number: 42, mergedAt: NOW, }), layer: ThreadSettlementService.layer.pipe(Layer.provide(dependencies)), }; }); const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( reactor: ThreadSettlementService.ThreadSettlementServiceV2["Service"], activation: Deferred.Deferred<void>, snapshotReads: Queue.Queue<number>, ) { yield* reactor.start(); yield* Deferred.succeed(activation, undefined);