From adb835640ce86013e9ad82803e6b8752cd5399ef Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 5 Aug 2026 04:29:23 -0700 Subject: [PATCH 1/3] fix(server): persist automatic thread settlement --- .../settings/DesktopClientSettings.test.ts | 1 - apps/mobile/src/features/home/HomeScreen.tsx | 34 +-- .../src/features/home/useThreadListActions.ts | 9 +- .../threads/ThreadNavigationSidebar.tsx | 34 +-- .../features/threads/thread-list-v2-items.tsx | 15 +- .../src/features/threads/threadListV2.test.ts | 33 ++- .../src/features/threads/threadListV2.ts | 40 +-- .../OrchestrationEngineHarness.integration.ts | 7 + .../Layers/OrchestrationReactor.test.ts | 11 + .../Layers/OrchestrationReactor.ts | 3 + .../Layers/ThreadSettlementReactor.test.ts | 165 +++++++++++ .../Layers/ThreadSettlementReactor.ts | 239 +++++++++++++++ .../Services/ThreadSettlementReactor.ts | 16 + apps/server/src/orchestration/decider.ts | 9 +- .../orchestration/threadSettlement.test.ts | 189 ++++++++++++ .../src/orchestration/threadSettlement.ts | 83 ++++++ apps/server/src/server.ts | 2 + apps/web/src/components/ChatView.tsx | 33 +-- apps/web/src/components/Sidebar.logic.test.ts | 39 +-- apps/web/src/components/Sidebar.logic.ts | 30 +- apps/web/src/components/SidebarV2.tsx | 83 +----- .../components/settings/BetaSettingsPanel.tsx | 41 +-- apps/web/src/hooks/useNowMinute.ts | 69 ----- apps/web/src/hooks/useThreadActions.ts | 9 +- docs/user/source-control.md | 7 + .../src/state/threadSettled.test.ts | 279 +----------------- .../client-runtime/src/state/threadSettled.ts | 103 +------ packages/contracts/src/settings.test.ts | 25 +- packages/contracts/src/settings.ts | 29 +- 29 files changed, 854 insertions(+), 783 deletions(-) create mode 100644 apps/server/src/orchestration/Layers/ThreadSettlementReactor.test.ts create mode 100644 apps/server/src/orchestration/Layers/ThreadSettlementReactor.ts create mode 100644 apps/server/src/orchestration/Services/ThreadSettlementReactor.ts create mode 100644 apps/server/src/orchestration/threadSettlement.test.ts create mode 100644 apps/server/src/orchestration/threadSettlement.ts delete mode 100644 apps/web/src/hooks/useNowMinute.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 53ef74f2191..287cab10de3 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -31,7 +31,6 @@ const clientSettings: ClientSettings = { fontSmoothing: true, glassOpacity: 80, providerModelPreferences: {}, - sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index ba32dc6b609..ee7541cd257 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -480,26 +480,6 @@ export function HomeScreen(props: HomeScreenProps) { // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells — no snapshot merging or // optimistic holds. - // PR states stream in per-row (rows own the VCS subscriptions); a merged or - // closed PR auto-settles its thread on the next partition (mirrors web). - const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, state: "open" | "closed" | "merged" | null) => { - setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? null) === state) return current; - const next = new Map(current); - if (state === null) { - next.delete(threadKey); - } else { - next.set(threadKey, state); - } - return next; - }); - }, - [], - ); const handleSettleThread = useCallback( (thread: EnvironmentThreadShell) => { void props.onSettleThread(thread); @@ -551,9 +531,8 @@ export function HomeScreen(props: HomeScreenProps) { const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); - // now is quantized to the minute and ticks so the inactivity auto-settle - // boundary is actually crossed while the app stays open (mirrors web); - // without a clock dependency the partition memoizes a frozen "now". + // The minute tick only refreshes snooze labels and preset choices; + // settlement itself is projected server state. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the // next wake boundary re-runs the partition with a fresh clock so a woken @@ -561,9 +540,8 @@ export function HomeScreen(props: HomeScreenProps) { const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { if (!threadListV2Enabled) return; - // Refresh immediately on enable: the mount-time value can be hours old - // by the time the beta is switched on, which would misclassify the - // inactivity auto-settle boundary until the first tick. + // Refresh immediately on enable so snooze labels do not inherit an old + // mount-time value. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); @@ -617,7 +595,6 @@ export function HomeScreen(props: HomeScreenProps) { projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, searchQuery: props.searchQuery, matchedThreadKeys, - changeRequestStateByKey, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, @@ -628,7 +605,6 @@ export function HomeScreen(props: HomeScreenProps) { selectedThreadKey: null, }); }, [ - changeRequestStateByKey, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -789,7 +765,6 @@ export function HomeScreen(props: HomeScreenProps) { onUnsettleThread={handleUnsettleThread} onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} - onChangeRequestState={handleChangeRequestState} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null } @@ -799,7 +774,6 @@ export function HomeScreen(props: HomeScreenProps) { ); }, [ - handleChangeRequestState, handleDeleteThread, handlePinThread, handleSettleThread, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index dcea2b6791b..1e5fcc27d3e 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -97,9 +97,8 @@ function useThreadActionExecutor( ); return false; } - // Settle may only target what effectiveSettled could classify as - // settled: not starting/running sessions, not threads waiting on - // approvals or user input. Anything else would hide live work. + // Mirror the server's explicit-settle guard so obviously blocked + // requests fail locally instead of making a round trip. if (action === "settle" && !canSettle(thread, { now: new Date().toISOString() })) { Alert.alert( actionFailureTitle(action), @@ -122,8 +121,8 @@ function useThreadActionExecutor( } const result = action === "unsettle" - ? // reason "user" pins the thread active: auto-settle stays - // suppressed until real activity clears the pin server-side. + ? // reason "user" holds the thread active: automation stays + // suppressed until real activity clears the override. await unsettleMutation({ environmentId: thread.environmentId, input: { threadId: thread.id, reason: "user" }, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 322ac60759d..0ba5d254df7 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -406,26 +406,6 @@ function ThreadNavigationSidebarPane( // Thread List v2 (beta) support — same model as the compact Home list // (HomeScreen.tsx): flat creation-order card block + settled recency tail. - // PR states stream in per-row; merged/closed PRs auto-settle their thread - // on the next partition. - const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, state: "open" | "closed" | "merged" | null) => { - setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? null) === state) return current; - const next = new Map(current); - if (state === null) { - next.delete(threadKey); - } else { - next.set(threadKey, state); - } - return next; - }); - }, - [], - ); // The settled tail renders in pages; expansion resets when the filter // context changes so environment/search flips never inherit a deep page. const [settledVisibleCount, setSettledVisibleCount] = useState( @@ -445,9 +425,8 @@ function ThreadNavigationSidebarPane( const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); - // now ticks per minute so the inactivity auto-settle boundary is actually - // crossed while the pane stays open; without a clock dependency the - // partition memoizes a frozen "now". + // The minute tick only refreshes snooze labels and preset choices; + // settlement itself is projected server state. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the // next wake boundary re-runs the partition with a fresh clock so a woken @@ -455,9 +434,8 @@ function ThreadNavigationSidebarPane( const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { if (!threadListV2Enabled) return; - // Refresh immediately on enable: the mount-time value can be hours old - // by the time the beta is switched on, which would misclassify the - // inactivity auto-settle boundary until the first tick. + // Refresh immediately on enable so snooze labels do not inherit an old + // mount-time value. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); @@ -509,7 +487,6 @@ function ThreadNavigationSidebarPane( projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, matchedThreadKeys, - changeRequestStateByKey, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, @@ -520,7 +497,6 @@ function ThreadNavigationSidebarPane( selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ - changeRequestStateByKey, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -934,7 +910,6 @@ function ThreadNavigationSidebarPane( onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} - onChangeRequestState={handleChangeRequestState} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -1056,7 +1031,6 @@ function ThreadNavigationSidebarPane( archiveThread, confirmDeletePendingTask, confirmDeleteThread, - handleChangeRequestState, handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 24f7166916b..8b8b6b9b479 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -356,12 +356,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly pinningSupported: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; - /** Reports this row's live PR state up so the partition can auto-settle - merged/closed work (mirrors web's onChangeRequestState). */ - readonly onChangeRequestState?: ( - threadKey: string, - state: "open" | "closed" | "merged" | null, - ) => void; readonly projectCwd?: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; @@ -382,17 +376,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onArchiveThread, onPinThread, onUnpinThread, - onChangeRequestState, } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); - const prState = pr?.state ?? null; - const threadKey = `${thread.environmentId}:${thread.id}`; - useEffect(() => { - onChangeRequestState?.(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); const screenColor = useThemeColor("--color-screen"); const drawerColor = useThemeColor("--color-drawer"); @@ -419,8 +407,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); // Swipe: the v2 primary action is the lifecycle transition. Every settled - // row can un-settle — explicit settles clear the override, auto-settled - // rows get pinned active until real activity clears the pin. + // row can un-settle, suppressing automation until real activity resets it. const canUnsettle = variant === "slim"; const [snoozeGateTick, bumpSnoozeGateTick] = useState(0); const snoozeGateExpiryMs = props.snoozeSupported diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 1316b3480c0..bc2917fe8ef 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -8,7 +8,6 @@ import { ProjectId, ProviderInstanceId, ThreadId, - TurnId, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -288,7 +287,7 @@ describe("buildThreadListV2Items", () => { expect(layout.snoozedCount).toBe(1); }); - it("renders pinned threads first and exempts them from auto-settle — parity with web", () => { + it("renders pinned threads first during a transient pin/settle projection overlap", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), @@ -572,13 +571,28 @@ describe("buildThreadListV2Items", () => { title: "Newer", createdAt: "2026-06-01T12:00:00.000Z", }), + makeThread({ + id: ThreadId.make("settled-first"), + title: "Settled first", + settledAt: "2026-06-01T13:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("settled-last"), + title: "Settled last", + settledAt: "2026-06-01T14:00:00.000Z", + }), ], environmentId: null, searchQuery: "", now: NOW, }); - expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]); + expect(items.map((item) => item.thread.id)).toEqual([ + "newer-created", + "older-created", + "settled-last", + "settled-first", + ]); }); it("keeps settled threads in the tail and filters by search query", () => { @@ -678,18 +692,7 @@ describe("buildThreadListV2Items settled paging", () => { id: ThreadId.make(`settled-${index}`), title: `Settled ${index}`, settledOverride: "settled", - settledAt: NOW, - latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`, - // A turn adopted the message (same requestedAt): without it the - // thread reads as a queued turn start, which never settles. - latestTurn: { - turnId: TurnId.make(`turn-${index}`), - state: "completed", - requestedAt: `2026-06-01T0${index}:00:00.000Z`, - startedAt: `2026-06-01T0${index}:00:00.000Z`, - completedAt: `2026-06-01T0${index}:10:00.000Z`, - assistantMessageId: null, - }, + settledAt: `2026-06-01T0${index}:10:00.000Z`, }), ), ]; diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index fa5f58d5d0e..c0f488af498 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,5 +1,4 @@ import { - effectiveSettled, effectiveSnoozed, hasQueuedTurnStart, QUEUED_TURN_START_GRACE_MS, @@ -146,17 +145,6 @@ function parseTimestampMs(isoDate: string): number { return Number.isNaN(parsed) ? 0 : parsed; } -/** First VALID timestamp wins: a present-yet-malformed string falls through - to the next candidate rather than sinking the row to the epoch. */ -function firstValidTimestampMs(...candidates: ReadonlyArray): number { - for (const candidate of candidates) { - if (candidate == null) continue; - const parsed = Date.parse(candidate); - if (!Number.isNaN(parsed)) return parsed; - } - return 0; -} - /** * v2 sort: static creation order, newest thread on top. Activity NEVER * reorders the list — a row holds its position from open until settled, so @@ -304,10 +292,8 @@ export function buildThreadListV2ListItems(input: { } /** - * Partitions visible threads into the active card block (creation order) and - * the settled recency tail, matching the web v2 list. `autoSettleAfterDays` - * mirrors the web default of 3 — mobile has no client-settings sync yet, so - * the default is fixed here rather than user-configurable. + * Partitions visible threads into the active card block and the settled + * recency tail. Settlement is persisted server state. */ export function buildThreadListV2Items(input: { readonly threads: ReadonlyArray; @@ -318,8 +304,6 @@ export function buildThreadListV2Items(input: { }> | null; readonly searchQuery: string; readonly matchedThreadKeys?: ReadonlySet; - /** Per-row PR state reported up by visible rows ("env:threadId" keys). */ - readonly changeRequestStateByKey?: ReadonlyMap; /** Environments whose server supports thread.settle/unsettle. Threads on other environments never classify as settled — the user could neither un-settle nor pin them. Absent = no gating (tests). */ @@ -327,7 +311,6 @@ export function buildThreadListV2Items(input: { /** Environments whose server supports thread.snooze/unsnooze. Same contract as settlementEnvironmentIds. */ readonly snoozeEnvironmentIds?: ReadonlySet; - readonly autoSettleAfterDays?: number; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; /** Injectable for tests; defaults to now. */ @@ -347,7 +330,6 @@ export function buildThreadListV2Items(input: { }): ThreadListV2Layout { const now = input.now ?? new Date().toISOString(); const snoozeNow = input.snoozeNow ?? now; - const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) @@ -359,8 +341,8 @@ export function buildThreadListV2Items(input: { const snoozed: EnvironmentThreadShell[] = []; let nextSnoozeWakeAt: string | null = null; for (const thread of input.threads) { - // Callers pass live (unarchived) shells; settled threads are among them - // and partition into the tail via effectiveSettled. + // Callers pass live (unarchived) shells; settled threads remain among + // them and partition into the tail from their projected timestamp. if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) { continue; @@ -379,8 +361,6 @@ export function buildThreadListV2Items(input: { } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; - const changeRequestState = - input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; // Visibility parity with web: snooze outranks everything, including a // pin — a snoozed thread leaves the list until it wakes (or raises its // hand). The pin survives underneath, so a woken thread reappears at @@ -396,16 +376,13 @@ export function buildThreadListV2Items(input: { } continue; } - // A pin otherwise overrides the lifecycle: pinned threads render above - // the inbox and never auto-settle out of sight. + // Server commands clear one side of a pin/settle transition atomically; + // pin wins here only while the paired projection event catches up. if (thread.pinnedAt != null) { pinned.push(thread); continue; } - if ( - supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) - ) { + if (supportsSettlement && thread.settledAt !== null) { settled.push(thread); } else { active.push(thread); @@ -426,8 +403,7 @@ export function buildThreadListV2Items(input: { ); const orderedSettled = [...settled].sort( (left, right) => - firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - - firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), + parseTimestampMs(right.settledAt ?? "") - parseTimestampMs(left.settledAt ?? ""), ); const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; const pagedSettled = diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index ebc4f984b86..ef7c12c38d3 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -59,6 +59,7 @@ import { type OrchestrationEngineShape, } from "../src/orchestration/Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; +import { ThreadSettlementReactor } from "../src/orchestration/Services/ThreadSettlementReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -366,6 +367,12 @@ export const makeOrchestrationIntegrationHarness = ( drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ThreadSettlementReactor, { + start: () => Effect.void, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 300d1526bb9..9d4dfcbf721 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import { ThreadSettlementReactor } from "../Services/ThreadSettlementReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; @@ -64,6 +65,15 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ThreadSettlementReactor, { + start: () => { + started.push("thread-settlement-reactor"); + return Effect.void; + }, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, @@ -85,6 +95,7 @@ describe("OrchestrationReactor", () => { "provider-command-reactor", "checkpoint-reactor", "thread-deletion-reactor", + "thread-settlement-reactor", "agent-awareness-relay", ]); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index fb7543e31af..4cfc1002689 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import { ThreadSettlementReactor } from "../Services/ThreadSettlementReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { @@ -16,6 +17,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const providerCommandReactor = yield* ProviderCommandReactor; const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; + const threadSettlementReactor = yield* ThreadSettlementReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { @@ -23,6 +25,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* providerCommandReactor.start(); yield* checkpointReactor.start(); yield* threadDeletionReactor.start(); + yield* threadSettlementReactor.start(); yield* agentAwarenessRelay.start(); }); diff --git a/apps/server/src/orchestration/Layers/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadSettlementReactor.test.ts new file mode 100644 index 00000000000..369baa6719c --- /dev/null +++ b/apps/server/src/orchestration/Layers/ThreadSettlementReactor.test.ts @@ -0,0 +1,165 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type ChangeRequest, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import { ServerSettingsService } from "../../serverSettings.ts"; +import * as SourceControlProvider from "../../sourceControl/SourceControlProvider.ts"; +import { SourceControlProviderRegistry } from "../../sourceControl/SourceControlProviderRegistry.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../Services/OrchestrationEngine.ts"; +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "../Services/ProjectionSnapshotQuery.ts"; +import { makeThreadSettlementReactor } from "./ThreadSettlementReactor.ts"; + +const projectId = ProjectId.make("project-1"); +const project: OrchestrationProjectShell = { + id: projectId, + title: "Project", + workspaceRoot: "/repo", + defaultModelSelection: null, + scripts: [], + createdAt: "2020-01-01T00:00:00.000Z", + updatedAt: "2020-01-01T00:00:00.000Z", +}; + +function makeThread(input: { + readonly id: string; + readonly branch: string; + readonly pinned?: boolean; +}): OrchestrationThreadShell { + const threadId = ThreadId.make(input.id); + return { + id: threadId, + projectId, + title: input.id, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: input.branch, + worktreePath: null, + latestTurn: { + turnId: TurnId.make(`turn-${input.id}`), + state: "completed", + // @effect/vitest's test clock starts at the Unix epoch. + requestedAt: "1960-01-01T00:00:00.000Z", + startedAt: null, + completedAt: null, + assistantMessageId: null, + }, + createdAt: "2020-01-01T00:00:00.000Z", + updatedAt: "2020-01-01T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + ...(input.pinned ? { pinnedAt: "2020-01-02T00:00:00.000Z" } : {}), + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +function changeRequest(headRefName: string, state: ChangeRequest["state"]): ChangeRequest { + return { + provider: "github", + number: 1, + title: headRefName, + url: `https://example.test/${headRefName}`, + baseRefName: "main", + headRefName, + state, + updatedAt: Option.none(), + }; +} + +it.effect( + "persists inactivity and merged-PR settlement while leaving pinned closed PRs alone", + () => + Effect.scoped( + Effect.gen(function* () { + const inactivity = makeThread({ id: "inactivity", branch: "feature/inactivity" }); + const pinnedClosed = makeThread({ + id: "pinned-closed", + branch: "feature/closed", + pinned: true, + }); + const pinnedMerged = makeThread({ + id: "pinned-merged", + branch: "feature/merged", + pinned: true, + }); + const snapshot = { + snapshotSequence: 1, + projects: [project], + threads: [inactivity, pinnedClosed, pinnedMerged], + updatedAt: "2020-01-02T00:00:00.000Z", + } satisfies OrchestrationShellSnapshot; + const dispatched = yield* Ref.make>([]); + + const provider = { + listChangeRequests: ( + input: Parameters< + SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] + >[0], + ) => + Effect.succeed( + input.headSelector === pinnedClosed.branch + ? [changeRequest(input.headSelector, "closed")] + : input.headSelector === pinnedMerged.branch + ? [changeRequest(input.headSelector, "merged")] + : [], + ), + } as unknown as SourceControlProvider.SourceControlProvider["Service"]; + + const dependencies = Layer.mergeAll( + Layer.succeed(OrchestrationEngineService, { + readEvents: () => Stream.empty, + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.as({ sequence: 1 }), + ), + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + } satisfies OrchestrationEngineShape), + Layer.succeed(ProjectionSnapshotQuery, { + getShellSnapshot: () => Effect.succeed(snapshot), + } as unknown as ProjectionSnapshotQueryShape), + ServerSettingsService.layerTest(), + Layer.mock(SourceControlProviderRegistry)({ + resolve: () => Effect.succeed(provider), + }), + NodeServices.layer, + ); + + const reactor = yield* makeThreadSettlementReactor.pipe(Effect.provide(dependencies)); + yield* reactor.start(); + yield* reactor.drain; + + const settledThreadIds = (yield* Ref.get(dispatched)) + .filter((command) => command.type === "thread.settle") + .map((command) => command.threadId) + .sort(); + expect(settledThreadIds).toEqual([inactivity.id, pinnedMerged.id].sort()); + }), + ), +); diff --git a/apps/server/src/orchestration/Layers/ThreadSettlementReactor.ts b/apps/server/src/orchestration/Layers/ThreadSettlementReactor.ts new file mode 100644 index 00000000000..9c8fac72e93 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ThreadSettlementReactor.ts @@ -0,0 +1,239 @@ +import { + CommandId, + type ChangeRequestState, + type OrchestrationProjectShell, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; +import { forkParked } from "../../serverActivation.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { normalizeSourceBranch } from "../../sourceControl/SourceControlProvider.ts"; +import { SourceControlProviderRegistry } from "../../sourceControl/SourceControlProviderRegistry.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { + ThreadSettlementReactor, + type ThreadSettlementReactorShape, +} from "../Services/ThreadSettlementReactor.ts"; +import { resolveAutomaticSettlementReason } from "../threadSettlement.ts"; + +const RECONCILE_INTERVAL = Duration.minutes(1); +const CHANGE_REQUEST_LOOKUP_TTL = Duration.minutes(2); +const CHANGE_REQUEST_LOOKUP_FAILURE_TTL = Duration.seconds(20); +const MAX_BRANCH_LOOKUPS_PER_RECONCILE = 20; + +function workspaceCwd( + thread: Pick, + projects: ReadonlyArray, +): string | undefined { + return resolveThreadWorkspaceCwd({ thread, projects }); +} + +function refsMatch(left: string, right: string): boolean { + return normalizeSourceBranch(left) === normalizeSourceBranch(right); +} + +export const makeThreadSettlementReactor = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const orchestrationEngine = yield* OrchestrationEngineService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const serverSettings = yield* ServerSettingsService; + const sourceControlProviders = yield* SourceControlProviderRegistry; + const branchCursor = yield* Ref.make(0); + + const branchLookupCache = yield* Cache.makeWith( + (key: string) => { + const [cwd = "", branch = ""] = key.split("\u0000"); + return sourceControlProviders.resolve({ cwd }).pipe( + Effect.flatMap((provider) => + provider.listChangeRequests({ + cwd, + headSelector: branch, + state: "all", + limit: 20, + }), + ), + Effect.map((changeRequests): ChangeRequestState | null => { + const matching = changeRequests.filter((changeRequest) => + refsMatch(changeRequest.headRefName, branch), + ); + return ( + matching.find((changeRequest) => changeRequest.state === "open")?.state ?? + matching[0]?.state ?? + null + ); + }), + ); + }, + { + capacity: 2_048, + timeToLive: (exit) => + Exit.isSuccess(exit) ? CHANGE_REQUEST_LOOKUP_TTL : CHANGE_REQUEST_LOOKUP_FAILURE_TTL, + }, + ); + + const dispatchSettlement = Effect.fn("ThreadSettlementReactor.dispatchSettlement")(function* ( + thread: OrchestrationThreadShell, + reason: "inactivity" | "pr-merged", + ) { + const commandId = CommandId.make( + `server:thread-auto-settle:${reason}:${yield* crypto.randomUUIDv4}`, + ); + yield* orchestrationEngine.dispatch({ + type: "thread.settle", + commandId, + threadId: thread.id, + }); + }); + + const dispatchSettlementSafely = ( + thread: OrchestrationThreadShell, + reason: "inactivity" | "pr-merged", + ) => + dispatchSettlement(thread, reason).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause); + return Effect.logDebug("automatic thread settlement skipped after a raced state change", { + threadId: thread.id, + reason, + cause: Cause.pretty(cause), + }); + }), + ); + + const lookupBranchChangeRequestState = (cwd: string, branch: string) => + Cache.get(branchLookupCache, `${cwd}\u0000${branch}`).pipe( + Effect.catch((error) => + Effect.logDebug("automatic thread settlement could not read change request state", { + cwdLength: cwd.length, + branch, + errorTag: error._tag, + }).pipe(Effect.as(null)), + ), + ); + + const resolveThreadChangeRequestState = Effect.fn( + "ThreadSettlementReactor.resolveThreadChangeRequestState", + )(function* (input: { + readonly thread: OrchestrationThreadShell; + readonly cwd: string | undefined; + }): Effect.fn.Return { + const branch = input.thread.branch; + if (branch === null || input.cwd === undefined) return null; + return yield* lookupBranchChangeRequestState(input.cwd, branch); + }); + + const reconcile = Effect.fn("ThreadSettlementReactor.reconcile")(function* () { + const [snapshot, settings, now] = yield* Effect.all([ + projectionSnapshotQuery.getShellSnapshot(), + serverSettings.getSettings, + DateTime.now, + ]); + const nowIso = DateTime.formatIso(now); + const eligibleThreads = snapshot.threads.filter((thread) => thread.settledOverride === null); + const cwdByThreadId = new Map( + eligibleThreads.map( + (thread) => [thread.id, workspaceCwd(thread, snapshot.projects)] as const, + ), + ); + const threadsWithoutChangeRequestLookup = eligibleThreads.filter( + (thread) => thread.branch === null || cwdByThreadId.get(thread.id) === undefined, + ); + const branchThreads = eligibleThreads.filter( + (thread) => thread.branch !== null && cwdByThreadId.get(thread.id) !== undefined, + ); + const branchStart = + branchThreads.length === 0 ? 0 : (yield* Ref.get(branchCursor)) % branchThreads.length; + const branchCount = Math.min(MAX_BRANCH_LOOKUPS_PER_RECONCILE, branchThreads.length); + const selectedBranchThreads = Array.from( + { length: branchCount }, + (_, offset) => branchThreads[(branchStart + offset) % branchThreads.length]!, + ); + if (branchThreads.length > 0) { + yield* Ref.set(branchCursor, (branchStart + branchCount) % branchThreads.length); + } + + yield* Effect.forEach( + threadsWithoutChangeRequestLookup, + (thread) => { + const reason = resolveAutomaticSettlementReason(thread, { + now: nowIso, + autoSettleAfterDays: settings.threadAutoSettleAfterDays, + changeRequestState: null, + }); + return reason === null ? Effect.void : dispatchSettlementSafely(thread, reason); + }, + { concurrency: 4, discard: true }, + ); + + // Branch state is remote provider work. Bound each pass and rotate the + // cursor so a large first-run history converges without launching one CLI + // process per thread at startup. + yield* Effect.forEach( + selectedBranchThreads, + (thread) => + resolveThreadChangeRequestState({ + thread, + cwd: cwdByThreadId.get(thread.id), + }).pipe( + Effect.flatMap((changeRequestState) => { + const reason = resolveAutomaticSettlementReason(thread, { + now: nowIso, + autoSettleAfterDays: settings.threadAutoSettleAfterDays, + changeRequestState, + }); + return reason === null ? Effect.void : dispatchSettlementSafely(thread, reason); + }), + ), + { concurrency: 4, discard: true }, + ); + }); + + const reconcileSafely = reconcile().pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause); + return Effect.logWarning("thread settlement reactor failed to reconcile", { + cause: Cause.pretty(cause), + }); + }), + ); + + const worker = yield* makeDrainableWorker((_input: void) => reconcileSafely); + + const start: ThreadSettlementReactorShape["start"] = Effect.fn("ThreadSettlementReactor.start")( + function* () { + yield* forkParked( + Stream.runForEach(serverSettings.streamChanges, () => worker.enqueue(undefined)), + ); + yield* worker.enqueue(undefined); + yield* forkParked( + Effect.sleep(RECONCILE_INTERVAL).pipe( + Effect.andThen(worker.enqueue(undefined)), + Effect.forever, + ), + ); + }, + ); + + return { + start, + drain: worker.drain, + } satisfies ThreadSettlementReactorShape; +}); + +export const ThreadSettlementReactorLive = Layer.effect( + ThreadSettlementReactor, + makeThreadSettlementReactor, +); diff --git a/apps/server/src/orchestration/Services/ThreadSettlementReactor.ts b/apps/server/src/orchestration/Services/ThreadSettlementReactor.ts new file mode 100644 index 00000000000..ce094e647dd --- /dev/null +++ b/apps/server/src/orchestration/Services/ThreadSettlementReactor.ts @@ -0,0 +1,16 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +export interface ThreadSettlementReactorShape { + /** Start the persisted inactivity and merged-PR settlement lifecycle. */ + readonly start: () => Effect.Effect; + + /** Resolves when all automatic settlement work already queued is complete. */ + readonly drain: Effect.Effect; +} + +export class ThreadSettlementReactor extends Context.Service< + ThreadSettlementReactor, + ThreadSettlementReactorShape +>()("t3/orchestration/Services/ThreadSettlementReactor") {} diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 5e5579ae93d..c539149bc8f 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -25,8 +25,8 @@ import { projectEvent } from "./projector.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); // Session adoption takes seconds; a user message still unadopted after this -// window is a failed/stale start, not pending work. Mirrors the client's -// QUEUED_TURN_START_GRACE_MS in client-runtime threadSettled.ts. +// window is a failed/stale start, not pending work. Keep this synchronized +// with the automatic settlement policy in threadSettlement.ts. const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; /** @@ -451,9 +451,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); - // Server-side twin of the client's canSettle session check: a stale - // or raced client must not settle a thread whose session is coming - // alive or working. + // The server owns this invariant: a stale or raced client must not + // settle a thread whose session is coming alive or working. if (thread.session?.status === "starting" || thread.session?.status === "running") { return yield* Effect.fail( new OrchestrationCommandInvariantError({ diff --git a/apps/server/src/orchestration/threadSettlement.test.ts b/apps/server/src/orchestration/threadSettlement.test.ts new file mode 100644 index 00000000000..6b2c80759be --- /dev/null +++ b/apps/server/src/orchestration/threadSettlement.test.ts @@ -0,0 +1,189 @@ +import { + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + resolveAutomaticSettlementReason, + shellHasQueuedTurnStart, + threadLastActivityAt, +} from "./threadSettlement.ts"; + +const NOW = "2026-04-10T00:00:00.000Z"; +const FRESH = "2026-04-09T00:00:00.000Z"; +const STALE = "2026-04-06T23:59:59.999Z"; + +function makeShell( + input: Partial & { + readonly activityAt?: string | null; + } = {}, +): OrchestrationThreadShell { + const threadId = ThreadId.make("thread-1"); + const { activityAt = FRESH, ...overrides } = input; + return { + id: threadId, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/settlement", + worktreePath: null, + latestTurn: + activityAt === null + ? null + : { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: activityAt, + startedAt: null, + completedAt: null, + assistantMessageId: null, + }, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + +function resolve( + shell: OrchestrationThreadShell, + options: { + readonly changeRequestState?: "open" | "merged" | "closed" | null; + readonly autoSettleAfterDays?: 3 | null; + readonly now?: string; + } = {}, +) { + return resolveAutomaticSettlementReason(shell, { + now: options.now ?? NOW, + autoSettleAfterDays: + options.autoSettleAfterDays === undefined ? 3 : options.autoSettleAfterDays, + changeRequestState: options.changeRequestState ?? null, + }); +} + +describe("resolveAutomaticSettlementReason", () => { + it("settles stale unpinned threads for inactivity", () => { + expect(resolve(makeShell({ activityAt: STALE }))).toBe("inactivity"); + expect(resolve(makeShell({ activityAt: "2026-04-07T00:00:00.000Z" }))).toBeNull(); + expect(resolve(makeShell({ activityAt: STALE }), { autoSettleAfterDays: null })).toBeNull(); + }); + + it("never timer-settles a pinned thread", () => { + const pinned = makeShell({ + activityAt: STALE, + pinnedAt: "2026-04-07T00:00:00.000Z", + }); + + expect(resolve(pinned)).toBeNull(); + expect(resolve(pinned, { changeRequestState: "closed" })).toBeNull(); + }); + + it("settles and therefore unpins a pinned thread only when its PR merges", () => { + const pinned = makeShell({ + activityAt: FRESH, + pinnedAt: "2026-04-07T00:00:00.000Z", + }); + + expect(resolve(pinned, { changeRequestState: "open" })).toBeNull(); + expect(resolve(pinned, { changeRequestState: "closed" })).toBeNull(); + expect(resolve(pinned, { changeRequestState: "merged" })).toBe("pr-merged"); + }); + + it("does not treat a closed PR as an immediate settlement signal", () => { + expect( + resolve(makeShell({ activityAt: FRESH }), { + autoSettleAfterDays: null, + changeRequestState: "closed", + }), + ).toBeNull(); + expect(resolve(makeShell({ activityAt: STALE }), { changeRequestState: "closed" })).toBe( + "inactivity", + ); + }); + + it("keeps open-PR threads active regardless of inactivity", () => { + expect(resolve(makeShell({ activityAt: STALE }), { changeRequestState: "open" })).toBeNull(); + }); + + it("honors explicit lifecycle state until real activity clears it", () => { + for (const settledOverride of ["active", "settled"] as const) { + expect( + resolve(makeShell({ activityAt: STALE, settledOverride }), { + changeRequestState: "merged", + }), + ).toBeNull(); + } + }); + + it("never settles live, blocked, or newly queued work", () => { + const queued = makeShell({ + activityAt: null, + latestUserMessageAt: "2026-04-09T12:00:00.000Z", + }); + const mergedOptions = { + now: "2026-04-09T12:00:30.000Z", + changeRequestState: "merged" as const, + }; + + expect(resolve(queued, mergedOptions)).toBeNull(); + expect(resolve(makeShell({ hasPendingApprovals: true }), mergedOptions)).toBeNull(); + expect(resolve(makeShell({ hasPendingUserInput: true }), mergedOptions)).toBeNull(); + expect( + resolve( + makeShell({ + session: { + threadId: queued.id, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: TurnId.make("turn-active"), + lastError: null, + updatedAt: NOW, + }, + }), + mergedOptions, + ), + ).toBeNull(); + }); +}); + +describe("server settlement activity guards", () => { + it("uses the latest user or turn activity timestamp", () => { + const shell = makeShell({ + latestUserMessageAt: "2026-04-04T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-04-03T00:00:00.000Z", + startedAt: "2026-04-05T00:00:00.000Z", + completedAt: "2026-04-06T00:00:00.000Z", + assistantMessageId: null, + }, + }); + + expect(threadLastActivityAt(shell)).toBe("2026-04-06T00:00:00.000Z"); + }); + + it("bounds the queued-turn guard so failed starts do not block forever", () => { + const shell = makeShell({ + activityAt: null, + latestUserMessageAt: "2026-04-09T12:00:00.000Z", + }); + + expect(shellHasQueuedTurnStart(shell, "2026-04-09T12:00:30.000Z")).toBe(true); + expect(shellHasQueuedTurnStart(shell, "2026-04-09T12:03:00.000Z")).toBe(false); + }); +}); diff --git a/apps/server/src/orchestration/threadSettlement.ts b/apps/server/src/orchestration/threadSettlement.ts new file mode 100644 index 00000000000..d9ee374c5a7 --- /dev/null +++ b/apps/server/src/orchestration/threadSettlement.ts @@ -0,0 +1,83 @@ +import type { + ChangeRequestState, + OrchestrationThreadShell, + ThreadAutoSettleAfterDays, +} from "@t3tools/contracts"; + +const DAY_MS = 24 * 60 * 60 * 1_000; + +// Session adoption takes seconds. A newer user message with no adopted turn +// is pending work, but old unmatched messages must not block settlement forever. +export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; + +export type AutomaticSettlementReason = "inactivity" | "pr-merged"; + +export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null { + const candidates = [ + shell.latestUserMessageAt, + shell.latestTurn?.requestedAt, + shell.latestTurn?.startedAt, + shell.latestTurn?.completedAt, + ]; + let latest: string | null = null; + let latestTimestamp = Number.NEGATIVE_INFINITY; + + for (const candidate of candidates) { + if (candidate == null) continue; + const timestamp = Date.parse(candidate); + if (timestamp > latestTimestamp) { + latest = candidate; + latestTimestamp = timestamp; + } + } + + return latest; +} + +export function shellHasQueuedTurnStart( + shell: Pick, + now: string, +): boolean { + if (shell.latestUserMessageAt == null || shell.session?.status === "error") return false; + const messageAt = Date.parse(shell.latestUserMessageAt); + const nowMs = Date.parse(now); + if (!Number.isFinite(messageAt) || !Number.isFinite(nowMs)) return false; + if (Math.abs(nowMs - messageAt) > QUEUED_TURN_START_GRACE_MS) return false; + if (shell.latestTurn === null) return true; + return [ + shell.latestTurn.requestedAt, + shell.latestTurn.startedAt, + shell.latestTurn.completedAt, + ].every((candidate) => candidate == null || Date.parse(candidate) < messageAt); +} + +/** + * Resolves the server-owned automatic settlement transition. Explicit user + * state wins, live or blocked work stays active, and a visible pin suppresses + * inactivity only. A merged PR is the one automatic signal allowed to settle + * and unpin a pinned thread. + */ +export function resolveAutomaticSettlementReason( + shell: OrchestrationThreadShell, + options: { + readonly now: string; + readonly autoSettleAfterDays: ThreadAutoSettleAfterDays | null; + readonly changeRequestState: ChangeRequestState | null; + }, +): AutomaticSettlementReason | null { + if (shell.settledOverride !== null) return null; + if (shell.hasPendingApprovals || shell.hasPendingUserInput) return null; + if (shell.session?.status === "starting" || shell.session?.status === "running") return null; + if (shellHasQueuedTurnStart(shell, options.now)) return null; + + if (options.changeRequestState === "merged") return "pr-merged"; + if (shell.pinnedAt != null) return null; + if (options.changeRequestState === "open") return null; + if (options.autoSettleAfterDays === null) return null; + + const lastActivityAt = threadLastActivityAt(shell); + if (lastActivityAt === null) return null; + return Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS + ? "inactivity" + : null; +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 534d216aade..7513e6772da 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -55,6 +55,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; +import { ThreadSettlementReactorLive } from "./orchestration/Layers/ThreadSettlementReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; @@ -235,6 +236,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), + Layer.provideMerge(ThreadSettlementReactorLive), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ad704d9e1b2..4783b891162 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,7 +26,7 @@ import { connectionStatusTitle, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; -import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import { parseScopedThreadKey, scopedThreadKey, @@ -167,8 +167,7 @@ import { import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; -import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; -import { useNowMinute } from "../hooks/useNowMinute"; +import { useEnvironmentSettings } from "../hooks/useSettings"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; @@ -238,7 +237,6 @@ import { shouldShowProviderStatusBanner, } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; -import { resolveThreadPr } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; import { @@ -3959,18 +3957,11 @@ function ChatViewContent(props: ChatViewProps) { : null, [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], ); - // Settled state of the open thread, resolved exactly like the sidebar - // partition (same shell, same capability gate, same PR auto-settle input) - // so the banner and the sidebar row never disagree. + // Settlement is projected server state. The open-thread banner and every + // navigation surface read the same persisted timestamp. const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); - const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); - const activeThreadPr = resolveThreadPr({ - threadBranch: activeThread?.branch ?? null, - gitStatus: gitStatusQuery.data ?? null, - }); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; - const nowMinute = useNowMinute(); const activeThreadSnoozed = activeThreadShell !== null && supportsSnooze && @@ -3987,20 +3978,8 @@ function ChatViewContent(props: ChatViewProps) { ); return () => window.clearTimeout(id); }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]); - const activeThreadSettled = useMemo(() => { - if (activeThreadShell === null || !supportsSettlement) return false; - return effectiveSettled(activeThreadShell, { - now: `${nowMinute}:00.000Z`, - autoSettleAfterDays, - changeRequestState: activeThreadPr?.state ?? null, - }); - }, [ - activeThreadPr?.state, - activeThreadShell, - autoSettleAfterDays, - nowMinute, - supportsSettlement, - ]); + const activeThreadSettled = + activeThreadShell !== null && supportsSettlement && activeThreadShell.settledAt !== null; const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false, }); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index ac2716a196e..04a23a99506 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -739,18 +739,9 @@ describe("sortThreadsForSidebarV2", () => { }); describe("sortSettledThreadsForSidebarV2", () => { - const settled = (input: { - id: string; - settledAt?: string | null; - latestUserMessageAt?: string | null; - latestTurn?: OrchestrationLatestTurn | null; - updatedAt?: string; - }) => ({ + const settled = (input: { id: string; settledAt?: string | null }) => ({ id: input.id, settledAt: input.settledAt ?? null, - latestUserMessageAt: input.latestUserMessageAt ?? null, - latestTurn: input.latestTurn ?? null, - updatedAt: input.updatedAt ?? "2026-03-09T09:00:00.000Z", }); it("orders by settle time, most recently settled first", () => { @@ -758,44 +749,16 @@ describe("sortSettledThreadsForSidebarV2", () => { settled({ id: "settled-first", settledAt: "2026-03-09T10:00:00.000Z", - // Created/active later than the other thread: settle time must win. - latestUserMessageAt: "2026-03-09T09:59:00.000Z", }), settled({ id: "settled-last", settledAt: "2026-03-09T12:00:00.000Z", - latestUserMessageAt: "2026-03-09T08:00:00.000Z", }), ]); expect(sorted.map((thread) => thread.id)).toEqual(["settled-last", "settled-first"]); }); - it("falls back to last activity for auto-settled threads without a settledAt stamp", () => { - const sorted = sortSettledThreadsForSidebarV2([ - settled({ id: "auto-old", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }), - settled({ id: "explicit", settledAt: "2026-03-09T10:00:00.000Z" }), - settled({ id: "auto-recent", latestUserMessageAt: "2026-03-09T11:00:00.000Z" }), - ]); - - expect(sorted.map((thread) => thread.id)).toEqual(["auto-recent", "explicit", "auto-old"]); - }); - - it("counts a turn completion as activity for auto-settled threads", () => { - // The message came in before the other thread's, but its turn finished - // after: completion time is the real "work ended" moment. - const sorted = sortSettledThreadsForSidebarV2([ - settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }), - settled({ - id: "completed-later", - latestUserMessageAt: "2026-03-09T10:00:00.000Z", - latestTurn: makeLatestTurn({ completedAt: "2026-03-09T10:30:00.000Z" }), - }), - ]); - - expect(sorted.map((thread) => thread.id)).toEqual(["completed-later", "message-only"]); - }); - it("breaks timestamp ties by id so the order is stable", () => { const sorted = sortSettledThreadsForSidebarV2([ settled({ id: "b", settledAt: "2026-03-09T10:00:00.000Z" }), diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 8025dffe3c3..1e4f07ec208 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -508,35 +508,11 @@ export function searchSidebarThreadsByTitle thread.title.toLowerCase().includes(normalizedQuery)); } -type SettledTimestampInput = Pick< - SidebarThreadSummary, - "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt" ->; +type SettledTimestampInput = Pick; -/** The timestamp a settled row sorts and labels by: settledAt when stamped - (explicit settles), otherwise last activity — the same candidates - threadLastActivityAt feeds the auto-settle window (user message plus all - latestTurn stamps), so a thread whose last activity was a turn completion - doesn't sort by an older message time. updatedAt is the final net. */ +/** The server timestamp a settled row sorts and labels by. */ export function resolveSettledTimestamp(thread: SettledTimestampInput): string | null { - const settledAt = firstValidTimestamp(thread.settledAt); - if (settledAt !== null) return settledAt; - let latest: string | null = null; - let latestMs = Number.NEGATIVE_INFINITY; - for (const candidate of [ - thread.latestUserMessageAt, - thread.latestTurn?.requestedAt, - thread.latestTurn?.startedAt, - thread.latestTurn?.completedAt, - ]) { - if (candidate == null) continue; - const parsed = Date.parse(candidate); - if (!Number.isNaN(parsed) && parsed > latestMs) { - latest = candidate; - latestMs = parsed; - } - } - return latest ?? firstValidTimestamp(thread.updatedAt); + return firstValidTimestamp(thread.settledAt); } // Settled rows are history, so they order by when the work ENDED, not when diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index e8f7f8bbb12..1938b8ca091 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -2,7 +2,6 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; import { canSnooze, - effectiveSettled, effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; @@ -90,7 +89,6 @@ import { openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; -import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; @@ -184,9 +182,7 @@ function threadTimeLabel(thread: SidebarThreadSummary): string { return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); } -// Settled rows read "how long ago did this wrap up", matching their sort -// key: both go through resolveSettledTimestamp so label and order can't -// disagree. +// Settled labels and ordering share the same projected server timestamp. function settledTimeLabel(thread: SidebarThreadSummary): string { const timestamp = resolveSettledTimestamp(thread); return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); @@ -425,11 +421,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onUnsettle: (threadRef: ScopedThreadRef) => void; onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; - onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; }) { const { isRenaming, - onChangeRequestState, onCancelRename, onCommitRename, onContextMenu, @@ -549,13 +543,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; - // Report the PR state up: the parent partitions rows with effectiveSettled, - // and a merged/closed PR auto-settles a thread — data only rows have. - const prState = pr?.state ?? null; - useEffect(() => { - onChangeRequestState(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); - const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; const driverKind = providerEntry?.driverKind ?? null; @@ -1195,7 +1182,6 @@ export default function SidebarV2() { const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -1364,9 +1350,6 @@ export default function SidebarV2() { [projectGroups], ); - // now is quantized to the minute so effectiveSettled memoization doesn't - // churn on every render; auto-settle thresholds are day-granular anyway. - const nowMinute = useNowMinute(); // Snooze wake times are second-precise, so classifying with the quantized // minute would hold a woken thread on the shelf for up to a minute. The // tick is a plain counter bumped exactly at the next wake boundary (armed @@ -1374,27 +1357,6 @@ export default function SidebarV2() { // fresh clock whenever it recomputes. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - // PR states stream in per-row (rows own the VCS subscriptions); a merged or - // closed PR auto-settles its thread on the next partition. - const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, state: "open" | "closed" | "merged" | null) => { - setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? null) === state) return current; - const next = new Map(current); - if (state === null) { - next.delete(threadKey); - } else { - next.set(threadKey, state); - } - return next; - }); - }, - [], - ); - // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. const [projectScopeKey, setProjectScopeKey] = useState(null); @@ -1586,11 +1548,8 @@ export default function SidebarV2() { const serverConfigs = useAtomValue(environmentServerConfigsAtom); const { pinnedThreads, activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { - const now = `${nowMinute}:00.000Z`; - // Snooze classification uses a REAL clock, not the quantized minute: - // wake times are second-precise and a woken thread must not linger on - // the shelf for the rest of the minute. snoozeWakeTick re-runs this - // memo exactly at the next wake boundary. + // Snooze classification uses a real clock because wake times are + // second-precise. Settlement itself is projected server state. void snoozeWakeTick; const preciseNow = new Date().toISOString(); const visible = threads.filter( @@ -1613,8 +1572,6 @@ export default function SidebarV2() { true; const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; // Snooze outranks everything, including a pin: "hide until Tuesday" // temporarily suspends "keep on top". The pin survives underneath — // pinned cards are creation-ordered, so on wake the thread reappears @@ -1623,16 +1580,11 @@ export default function SidebarV2() { // stronger statement about when the thread matters again.) if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { snoozed.push(thread); - // A pin otherwise overrides the lifecycle: pinned threads never - // auto-settle out of sight. (The decider clears settled state on - // pin and the pin on settle, so pin-vs-settled conflicts only - // arise from stale or raced writes.) + // The server clears settled state on pin and clears the pin on + // settle, so any overlap here is only a transient event race. } else if (thread.pinnedAt != null) { pinned.push(thread); - } else if ( - supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) - ) { + } else if (supportsSettlement && thread.settledAt !== null) { settled.push(thread); } else { active.push(thread); @@ -1652,15 +1604,7 @@ export default function SidebarV2() { settledThreads: sortSettledThreadsForSidebarV2(settled), snoozeNow: preciseNow, }; - }, [ - autoSettleAfterDays, - changeRequestStateByKey, - nowMinute, - scopedProjectKeys, - serverConfigs, - snoozeWakeTick, - threads, - ]); + }, [scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); @@ -2363,10 +2307,9 @@ export default function SidebarV2() { thread.worktreePath ?? projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null; - // Un-settle works on every settled row: for explicit settles it - // clears the override, for auto-settled rows it pins the thread - // active until real activity clears the pin. Environments without - // the settlement capability get no lifecycle items at all. + // Un-settle works on every settled row and suppresses automation + // until real activity resets the lifecycle. Environments without the + // settlement capability get no lifecycle items at all. const supportsSettlement = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; @@ -2945,9 +2888,8 @@ export default function SidebarV2() { key={`${threadKey}:${rowVariant}`} thread={thread} variant={rowVariant} - // Snoozed rows wake; settled rows un-settle (explicit - // settles clear the override, auto-settled rows get - // pinned active); cards settle. + // Snoozed rows wake, settled rows un-settle, and cards + // settle. The server owns every lifecycle transition. variantAction={ section === "snoozed" ? "unsnooze" @@ -3002,7 +2944,6 @@ export default function SidebarV2() { onUnsettle={attemptUnsettle} onSnooze={attemptSnooze} onUnsnooze={attemptUnsnooze} - onChangeRequestState={handleChangeRequestState} /> ); }; diff --git a/apps/web/src/components/settings/BetaSettingsPanel.tsx b/apps/web/src/components/settings/BetaSettingsPanel.tsx index 740d3048f0e..8d57c46e599 100644 --- a/apps/web/src/components/settings/BetaSettingsPanel.tsx +++ b/apps/web/src/components/settings/BetaSettingsPanel.tsx @@ -1,19 +1,20 @@ import { useEffect, useState } from "react"; +import { + DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS, + MAX_THREAD_AUTO_SETTLE_AFTER_DAYS, + MIN_THREAD_AUTO_SETTLE_AFTER_DAYS, +} from "@t3tools/contracts"; import { - useClientSettings, + usePrimarySettings, useSidebarV2Enabled, - useUpdateClientSettings, + useUpdatePrimarySettings, } from "../../hooks/useSettings"; import { Input } from "../ui/input"; import { Switch } from "../ui/switch"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; -const AUTO_SETTLE_MIN_DAYS = 1; -const AUTO_SETTLE_MAX_DAYS = 90; -const AUTO_SETTLE_DEFAULT_DAYS = 3; - function AutoSettleDaysInput({ value, onCommit, @@ -31,8 +32,8 @@ function AutoSettleDaysInput({ return ( { @@ -43,8 +44,8 @@ function AutoSettleDaysInput({ const parsed = Number(event.target.value); if ( Number.isInteger(parsed) && - parsed >= AUTO_SETTLE_MIN_DAYS && - parsed <= AUTO_SETTLE_MAX_DAYS + parsed >= MIN_THREAD_AUTO_SETTLE_AFTER_DAYS && + parsed <= MAX_THREAD_AUTO_SETTLE_AFTER_DAYS ) { onCommit(parsed); } @@ -57,10 +58,10 @@ function AutoSettleDaysInput({ export function BetaSettingsPanel() { const sidebarV2Enabled = useSidebarV2Enabled(); - const sidebarAutoSettleAfterDays = useClientSettings( - (settings) => settings.sidebarAutoSettleAfterDays, + const threadAutoSettleAfterDays = usePrimarySettings( + (settings) => settings.threadAutoSettleAfterDays, ); - const updateSettings = useUpdateClientSettings(); + const updateSettings = useUpdatePrimarySettings(); return ( @@ -87,27 +88,29 @@ export function BetaSettingsPanel() { <> updateSettings({ - sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, + threadAutoSettleAfterDays: checked + ? DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS + : null, }) } aria-label="Auto-settle inactive threads" /> } /> - {sidebarAutoSettleAfterDays !== null ? ( + {threadAutoSettleAfterDays !== null ? ( updateSettings({ sidebarAutoSettleAfterDays: days })} + value={threadAutoSettleAfterDays} + onCommit={(days) => updateSettings({ threadAutoSettleAfterDays: days })} /> } /> diff --git a/apps/web/src/hooks/useNowMinute.ts b/apps/web/src/hooks/useNowMinute.ts deleted file mode 100644 index 1b9f77b2189..00000000000 --- a/apps/web/src/hooks/useNowMinute.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useSyncExternalStore } from "react"; - -/** Minute-quantized clock ("YYYY-MM-DDTHH:MM") for settled-state resolution. - One module-level timer feeds every consumer through useSyncExternalStore, - so all surfaces resolving effectiveSettled against it (sidebar partition, - composer banner) share a single value by construction and tick on UTC - minute boundaries together. */ - -function currentMinute(): string { - return new Date().toISOString().slice(0, 16); -} - -let nowMinute = currentMinute(); -let timerId: number | null = null; -let timerIsInterval = false; -const listeners = new Set<() => void>(); - -function tick(): void { - const next = currentMinute(); - if (next !== nowMinute) { - nowMinute = next; - for (const listener of listeners) listener(); - } -} - -function startTimer(): void { - // Align to the next UTC minute boundary, then tick every 60s. Ticks re-read - // the clock, so a throttled or late timer self-corrects when it fires. - timerIsInterval = false; - timerId = window.setTimeout( - () => { - tick(); - timerIsInterval = true; - timerId = window.setInterval(tick, 60_000); - }, - 60_000 - (Date.now() % 60_000), - ); -} - -function subscribe(listener: () => void): () => void { - if (listeners.size === 0) { - startTimer(); - } - listeners.add(listener); - return () => { - listeners.delete(listener); - if (listeners.size === 0 && timerId !== null) { - if (timerIsInterval) window.clearInterval(timerId); - else window.clearTimeout(timerId); - timerId = null; - } - }; -} - -function getSnapshot(): string { - // With no timer running (no subscribers yet — e.g. the first render after - // a full unmount), the stored minute may be stale; re-read it so a fresh - // mount renders the current minute instead of waiting for the first tick. - // While the timer runs the cached value is returned untouched, as - // useSyncExternalStore requires between change notifications. - if (timerId === null) { - nowMinute = currentMinute(); - } - return nowMinute; -} - -export function useNowMinute(): string { - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); -} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 96ec645551a..ea5a2ede9f7 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -446,9 +446,8 @@ export function useThreadActions() { ); } const resolved = resolveThreadTarget(target); - // Settle may only target what effectiveSettled could classify as - // settled: not starting/running sessions, not threads waiting on - // approvals or user input. Anything else would hide live work. + // Mirror the server's explicit-settle guard so obviously blocked + // requests fail locally instead of making a round trip. if (resolved && !canSettle(resolved.thread, { now: new Date().toISOString() })) { return AsyncResult.failure( Cause.fail( @@ -481,8 +480,8 @@ export function useThreadActions() { ), ); } - // reason "user" pins the thread active: auto-settle (PR merged / - // inactivity) stays suppressed until real activity clears the pin. + // reason "user" holds the thread active: automation stays suppressed + // until real activity clears the override. return unsettleThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId, reason: "user" }, diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 6d81d2b33ab..7ef385352ee 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -41,6 +41,13 @@ T3 Code works with the platforms your team already uses: - Open the review directly in your browser with one click - Check out a teammate's branch to review code locally +**Let completed reviews leave the inbox** + +- T3 Code settles a thread after its PR or MR merges +- Pinned threads ignore the inactivity timer and settle automatically only after a merge +- Closing a PR or MR without merging it does not immediately settle the thread +- The server owns this state, so web, desktop, and mobile show the same result + ### Know Your Setup at a Glance The **Source Control settings** page shows you exactly what's connected: diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index a7dd4b1eab8..d613bed7943 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -7,17 +7,10 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { - canSettle, - effectiveSettled, - hasQueuedTurnStart, - threadLastActivityAt, - type ChangeRequestStateLike, -} from "./threadSettled.ts"; +import { canSettle, hasQueuedTurnStart } from "./threadSettled.ts"; const NOW = "2026-04-10T00:00:00.000Z"; const FRESH = "2026-04-09T00:00:00.000Z"; -const STALE = "2026-04-06T23:59:59.999Z"; function makeShell(input: { readonly settledOverride?: "settled" | "active" | null; @@ -70,219 +63,6 @@ function makeShell(input: { }; } -describe("threadLastActivityAt", () => { - it("returns the latest real user or turn activity and ignores thread/session updates", () => { - const shell = makeShell({ activityAt: null, sessionStatus: "running" }); - const withActivity: OrchestrationThreadShell = { - ...shell, - latestUserMessageAt: "2026-04-04T00:00:00.000Z", - latestTurn: { - turnId: TurnId.make("turn-1"), - state: "completed", - requestedAt: "2026-04-03T00:00:00.000Z", - startedAt: "2026-04-05T00:00:00.000Z", - completedAt: "2026-04-06T00:00:00.000Z", - assistantMessageId: null, - }, - }; - - expect(threadLastActivityAt(withActivity)).toBe("2026-04-06T00:00:00.000Z"); - expect(threadLastActivityAt(shell)).toBeNull(); - }); -}); - -describe("effectiveSettled", () => { - const overrideCases = [null, "settled", "active"] as const; - const changeRequestStates = [undefined, "open", "merged"] as const; - const inactivityCases = [ - ["fresh", FRESH], - ["stale", STALE], - ["no-activity", null], - ] as const; - const runningCases = [false, true] as const; - const pendingCases = [undefined, "approval", "user-input"] as const; - const truthTable = overrideCases.flatMap((settledOverride) => - changeRequestStates.flatMap((changeRequestState) => - inactivityCases.flatMap(([inactivity, activityAt]) => - runningCases.flatMap((running) => - pendingCases.map((pending) => ({ - settledOverride, - changeRequestState, - inactivity, - activityAt, - running, - pending, - // Settled iff nothing blocks (pending work / live session) AND - // the override says settled, or (with no override) a merged PR - // or staleness auto-settles. The "active" pin suppresses both - // auto signals, and an open PR suppresses the inactivity path: - // a thread with a PR out for review is never done, however quiet. - expected: - pending === undefined && - !running && - (settledOverride === "settled" || - (settledOverride === null && - (changeRequestState === "merged" || - (changeRequestState !== "open" && inactivity === "stale")))), - })), - ), - ), - ), - ); - - it.each(truthTable)( - "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending", - ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => { - const shell = makeShell({ - settledOverride, - activityAt, - ...(running ? { sessionStatus: "running" as const } : {}), - ...(pending === undefined ? {} : { pending }), - }); - const changeRequestOptions = - changeRequestState === undefined - ? {} - : { changeRequestState: changeRequestState as ChangeRequestStateLike }; - - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: 3, - ...changeRequestOptions, - }), - ).toBe(expected); - }, - ); - - it("treats closed change requests like merged ones", () => { - const shell = makeShell({ activityAt: null }); - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: null, - changeRequestState: "closed", - }), - ).toBe(true); - }); - - it("settles immediately when a change request merges or closes", () => { - const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" }); - for (const changeRequestState of ["merged", "closed"] as const) { - expect( - effectiveSettled(recentlyActive, { - now: NOW, - autoSettleAfterDays: null, - changeRequestState, - }), - ).toBe(true); - } - }); - - it("never auto-settles a stale thread with an open change request", () => { - const stale = makeShell({ activityAt: STALE }); - expect( - effectiveSettled(stale, { - now: NOW, - autoSettleAfterDays: 3, - changeRequestState: "open", - }), - ).toBe(false); - // An explicit user settle still wins: open PR only blocks the auto path. - const settled = makeShell({ settledOverride: "settled", activityAt: STALE }); - expect( - effectiveSettled(settled, { - now: NOW, - autoSettleAfterDays: 3, - changeRequestState: "open", - }), - ).toBe(true); - }); - - it("keeps an explicitly un-settled merged-PR thread active", () => { - const shell = makeShell({ - settledOverride: "active", - activityAt: "2026-04-09T23:59:59.999Z", - }); - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: null, - changeRequestState: "merged", - }), - ).toBe(false); - }); - - it("never settles a starting session, even with a settled override", () => { - const shell = makeShell({ - settledOverride: "settled", - activityAt: STALE, - sessionStatus: "starting", - }); - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: 3, - changeRequestState: "merged", - }), - ).toBe(false); - }); - - it("keeps a new turn active from queued through starting and running", () => { - const requestedAt = "2026-04-09T12:00:00.000Z"; - const transitionNow = "2026-04-09T12:00:30.000Z"; - const base = makeShell({ - settledOverride: null, - activityAt: STALE, - }); - const queued: OrchestrationThreadShell = { - ...base, - latestUserMessageAt: requestedAt, - latestTurn: null, - session: null, - }; - const starting: OrchestrationThreadShell = { - ...queued, - session: { - threadId: queued.id, - status: "starting", - providerName: "Codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: requestedAt, - }, - }; - const running: OrchestrationThreadShell = { - ...starting, - session: { - ...starting.session!, - status: "running", - activeTurnId: TurnId.make("turn-new"), - }, - }; - - for (const shell of [queued, starting, running]) { - expect( - effectiveSettled(shell, { - now: transitionNow, - autoSettleAfterDays: 3, - changeRequestState: "merged", - }), - ).toBe(false); - } - }); - - it("uses a strict inactivity boundary and honors a null threshold", () => { - const boundary = makeShell({ - activityAt: "2026-04-07T00:00:00.000Z", - }); - const stale = makeShell({ activityAt: STALE }); - - expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); - expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false); - }); -}); - describe("hasQueuedTurnStart", () => { const QUEUED_AT = "2026-04-09T12:00:00.000Z"; // Within the adoption grace window of the queued message. @@ -355,7 +135,7 @@ describe("hasQueuedTurnStart", () => { }); describe("canSettle", () => { - it("blocks every state effectiveSettled refuses to classify as settled", () => { + it("blocks live or pending work", () => { expect(canSettle(makeShell({ activityAt: FRESH }), { now: NOW })).toBe(true); expect( canSettle(makeShell({ activityAt: FRESH, sessionStatus: "starting" }), { now: NOW }), @@ -378,62 +158,7 @@ describe("canSettle", () => { }; const justAfter = "2026-04-09T12:00:30.000Z"; expect(canSettle(queued, { now: justAfter })).toBe(false); - // effectiveSettled must agree: queued work never auto-settles either, - // even with a merged PR. - expect( - effectiveSettled(queued, { - now: justAfter, - autoSettleAfterDays: 3, - changeRequestState: "merged", - }), - ).toBe(false); // Past the window the message is a failed/stale start: settleable again. expect(canSettle(queued, { now: NOW })).toBe(true); }); - - it("lets a server-accepted settle overrule the clock-derived queued blocker", () => { - // The settle action ran with wall-clock `now` (past the grace window); - // the list partition re-evaluates with a minute-floored `now` that is - // still INSIDE the window. settledAt >= message time proves the server - // already adjudicated this exact message, so the row must not snap back - // to active until the coarser clock catches up. - const messageAt = "2026-04-09T12:00:00.000Z"; - const flooredNow = "2026-04-09T12:01:00.000Z"; - const base = makeShell({ settledOverride: "settled", activityAt: null }); - const settledAfterMessage = { - ...base, - latestUserMessageAt: messageAt, - settledAt: "2026-04-09T12:02:10.000Z", - }; - expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true); - expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe( - true, - ); - - // A message NEWER than settledAt is genuinely new work: still blocked - // until the server's auto-unsettle lands. - const messageAfterSettle = { - ...base, - latestUserMessageAt: "2026-04-09T12:03:00.000Z", - settledAt: "2026-04-09T12:02:10.000Z", - }; - expect( - effectiveSettled(messageAfterSettle, { - now: "2026-04-09T12:03:30.000Z", - autoSettleAfterDays: 3, - }), - ).toBe(false); - }); - - it("agrees with effectiveSettled's blockers for explicitly settled shells", () => { - // Anything canSettle rejects must render as active even when the user - // settled it earlier. - const blocked = makeShell({ - settledOverride: "settled", - activityAt: FRESH, - pending: "user-input", - }); - expect(canSettle(blocked, { now: NOW })).toBe(false); - expect(effectiveSettled(blocked, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); - }); }); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 595b1303bea..95d6912d9c0 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,32 +1,6 @@ // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. import type { OrchestrationThreadShell } from "@t3tools/contracts"; -export type ChangeRequestStateLike = "open" | "closed" | "merged"; - -const DAY_MS = 24 * 60 * 60 * 1_000; - -export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null { - const candidates = [ - shell.latestUserMessageAt, - shell.latestTurn?.requestedAt, - shell.latestTurn?.startedAt, - shell.latestTurn?.completedAt, - ]; - let latest: string | null = null; - let latestTimestamp = Number.NEGATIVE_INFINITY; - - for (const candidate of candidates) { - if (candidate === null || candidate === undefined) continue; - const timestamp = Date.parse(candidate); - if (timestamp > latestTimestamp) { - latest = candidate; - latestTimestamp = timestamp; - } - } - - return latest; -} - /** * A queued turn start lives for at most this long: session adoption takes * seconds, so a user message still unadopted after the grace window is a @@ -70,11 +44,8 @@ export function hasQueuedTurnStart( } /** - * A thread may be settled only when none of effectiveSettled's activity - * blockers hold. This is deliberately the same list: anything the partition - * refuses to CLASSIFY as settled must also be refused as a settle TARGET. - * The server enforces its own invariants; this client-side twin exists so - * the UI can disable/reject before a round trip. + * Client-side affordance guard for explicit settlement. The server owns and + * persists the actual lifecycle transition and enforces the same invariants. */ export function canSettle( shell: Pick< @@ -86,7 +57,7 @@ export function canSettle( if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; if (shell.session?.status === "starting" || shell.session?.status === "running") return false; // Queued work is as blocked-on-progress as a live session: settling it - // (or auto-settling it on a closed PR) would hide a just-requested turn. + // would hide a just-requested turn. if (hasQueuedTurnStart(shell, options)) return false; return true; } @@ -114,7 +85,7 @@ export type ThreadSnoozeShell = Pick< * v1 taste of event-based snooze ("something happened" wakes early). * Raising a hand never clears the server-side snooze fields; it only stops * the thread from CLASSIFYING as snoozed, exactly like blocked work and - * effectiveSettled. + * the persisted settlement lifecycle. */ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean { if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true; @@ -215,72 +186,8 @@ export function threadWokeAt( return wakeAtMs <= Date.parse(options.now) ? shell.snoozedUntil : null; } -/** - * Settled resolution over the server-backed settled lifecycle. Activity - * blockers (pending approval/user-input, a live session, an unadjudicated - * queued turn) are checked first and hold a thread active regardless of any - * override. Past the blockers, the explicit user override (thread.settle / - * thread.unsettle commands, projected into settledOverride + settledAt) - * wins in both directions; without one, a thread auto-settles on a - * merged/closed PR immediately or on inactivity past the window — except - * that an open PR blocks the inactivity path entirely. The server - * un-settles on real activity (user message, session start, approval/ - * user-input request), so an override never goes stale silently. - */ -export function effectiveSettled( - shell: OrchestrationThreadShell, - options: { - readonly now: string; - readonly autoSettleAfterDays: number | null; - readonly changeRequestState?: ChangeRequestStateLike | null; - }, -): boolean { - // Blocked work must remain visible even when a user explicitly settled it. - if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; - if (shell.session?.status === "starting" || shell.session?.status === "running") return false; - if (hasQueuedTurnStart(shell, { now: options.now })) { - // The queued-turn blocker alone is forgivable: it is clock-derived, and - // list callers pass a coarser `now` than the settle action used. When - // the server already adjudicated the queued message by accepting a - // settle after it (settledAt stamps server accept time), trust that - // ruling — otherwise a settle near the grace boundary leaves the row - // pinned active until the caller's clock ticks over. A message NEWER - // than settledAt is genuinely new work and keeps the block until the - // server's auto-unsettle lands. - const serverAdjudicated = - shell.settledOverride === "settled" && - shell.settledAt !== null && - shell.latestUserMessageAt !== null && - Date.parse(shell.settledAt) >= Date.parse(shell.latestUserMessageAt); - if (!serverAdjudicated) return false; - } - if (shell.settledOverride === "settled") return true; - // "active" is the explicit keep-active pin: it suppresses auto-settle - // until real activity clears it server-side. - if (shell.settledOverride === "active") return false; - if (options.changeRequestState === "merged" || options.changeRequestState === "closed") { - return true; - } - // An open PR is unfinished business regardless of how long the thread has - // been quiet: review can take days, and hiding the thread would bury the - // work waiting on it. Only merge/close (above) or an explicit user settle - // resolves it. - if (options.changeRequestState === "open") return false; - if (options.autoSettleAfterDays === null) return false; - - const lastActivityAt = threadLastActivityAt(shell); - if (lastActivityAt === null) return false; - - // threadLastActivityAt only returns candidates whose Date.parse beat - // -Infinity, so this parse is a real number; a malformed `now` yields NaN, - // the comparison is false, and the thread stays active (never a surprise - // auto-settle on bad input). - return ( - Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS - ); -} - const HOUR_MS = 60 * 60 * 1_000; +const DAY_MS = 24 * HOUR_MS; const EVENING_HOUR = 18; const MORNING_HOUR = 9; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 5bd22e95f20..d95769dd6e7 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -68,10 +68,9 @@ describe("ClientSettings environment identification", () => { }); describe("ClientSettings sidebar v2", () => { - it("defaults the beta off with a three-day auto-settle threshold", () => { + it("defaults the beta off", () => { const settings = decodeClientSettings({}); expect(settings.sidebarV2Enabled).toBe(false); - expect(settings.sidebarAutoSettleAfterDays).toBe(3); }); it("treats settings written before the beta had a per-channel default as unconfigured", () => { @@ -99,15 +98,29 @@ describe("ClientSettings sidebar v2", () => { expect(patch.sidebarV2ConfiguredByUser).toBe(true); }); - it("allows auto-settle by inactivity to be disabled", () => { + it("drops the former client-only auto-settle setting", () => { + const decoded = decodeClientSettings({ sidebarAutoSettleAfterDays: null }); + const patch = decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: 14 }); + + expect(decoded).not.toHaveProperty("sidebarAutoSettleAfterDays"); + expect(patch).not.toHaveProperty("sidebarAutoSettleAfterDays"); + }); +}); + +describe("ServerSettings thread auto-settle", () => { + it("defaults to three days and can be disabled", () => { + expect(decodeServerSettings({}).threadAutoSettleAfterDays).toBe(3); + expect( + decodeServerSettings({ threadAutoSettleAfterDays: null }).threadAutoSettleAfterDays, + ).toBe(null); expect( - decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, + decodeServerSettingsPatch({ threadAutoSettleAfterDays: null }).threadAutoSettleAfterDays, ).toBeNull(); }); it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => { - expect(() => decodeClientSettings({ sidebarAutoSettleAfterDays: value })).toThrow(); - expect(() => decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow(); + expect(() => decodeServerSettings({ threadAutoSettleAfterDays: value })).toThrow(); + expect(() => decodeServerSettingsPatch({ threadAutoSettleAfterDays: value })).toThrow(); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cbb547b95fb..9fd45af590a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -42,16 +42,6 @@ export const SidebarThreadPreviewCount = Schema.Int.check( ); export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; -export const MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 1; -export const MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 90; -export const SidebarAutoSettleAfterDays = Schema.Number.check( - Schema.isBetween({ - minimum: MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, - maximum: MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, - }), -); -export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type; -export const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; export const MIN_GLASS_OPACITY = 40; export const MAX_GLASS_OPACITY = 100; export const GlassOpacity = Schema.Int.check( @@ -168,9 +158,6 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), - sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), - ), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -205,6 +192,17 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS // ── Server Settings (server-authoritative) ──────────────────── +export const MIN_THREAD_AUTO_SETTLE_AFTER_DAYS = 1; +export const MAX_THREAD_AUTO_SETTLE_AFTER_DAYS = 90; +export const ThreadAutoSettleAfterDays = Schema.Number.check( + Schema.isBetween({ + minimum: MIN_THREAD_AUTO_SETTLE_AFTER_DAYS, + maximum: MAX_THREAD_AUTO_SETTLE_AFTER_DAYS, + }), +); +export type ThreadAutoSettleAfterDays = typeof ThreadAutoSettleAfterDays.Type; +export const DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS: ThreadAutoSettleAfterDays = 3; + export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); export type ThreadEnvMode = typeof ThreadEnvMode.Type; @@ -537,6 +535,9 @@ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + threadAutoSettleAfterDays: Schema.NullOr(ThreadAutoSettleAfterDays).pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS)), + ), backgroundActivity: BackgroundActivitySettings, // Legacy flat fields retained for old settings files and old clients. New // consumers should resolve `backgroundActivity` instead. @@ -700,6 +701,7 @@ export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), + threadAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(ThreadAutoSettleAfterDays)), backgroundActivity: Schema.optionalKey( Schema.Struct({ schemaVersion: Schema.optionalKey(Schema.Literal(1)), @@ -783,7 +785,6 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), - sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), From 836902d2bb183bb50576eee27910fcf05c9a77f3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 5 Aug 2026 04:38:05 -0700 Subject: [PATCH 2/3] fix(server): skip settlement when PR lookup fails --- .../Layers/ThreadSettlementReactor.test.ts | 52 +++++++++++++++++++ .../Layers/ThreadSettlementReactor.ts | 8 ++- packages/contracts/src/settings.ts | 3 ++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadSettlementReactor.test.ts index 369baa6719c..7ddb6dd3dda 100644 --- a/apps/server/src/orchestration/Layers/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadSettlementReactor.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProjectId, ProviderInstanceId, + SourceControlProviderError, ThreadId, TurnId, type ChangeRequest, @@ -163,3 +164,54 @@ it.effect( }), ), ); + +it.effect("does not settle a stale thread when its PR state lookup fails", () => + Effect.scoped( + Effect.gen(function* () { + const thread = makeThread({ id: "lookup-failed", branch: "feature/open-pr" }); + const snapshot = { + snapshotSequence: 1, + projects: [project], + threads: [thread], + updatedAt: "2020-01-02T00:00:00.000Z", + } satisfies OrchestrationShellSnapshot; + const dispatched = yield* Ref.make>([]); + const provider = { + listChangeRequests: () => + Effect.fail( + new SourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + cwd: project.workspaceRoot, + detail: "temporary lookup failure", + }), + ), + } as unknown as SourceControlProvider.SourceControlProvider["Service"]; + const dependencies = Layer.mergeAll( + Layer.succeed(OrchestrationEngineService, { + readEvents: () => Stream.empty, + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.as({ sequence: 1 }), + ), + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + } satisfies OrchestrationEngineShape), + Layer.succeed(ProjectionSnapshotQuery, { + getShellSnapshot: () => Effect.succeed(snapshot), + } as unknown as ProjectionSnapshotQueryShape), + ServerSettingsService.layerTest(), + Layer.mock(SourceControlProviderRegistry)({ + resolve: () => Effect.succeed(provider), + }), + NodeServices.layer, + ); + + const reactor = yield* makeThreadSettlementReactor.pipe(Effect.provide(dependencies)); + yield* reactor.start(); + yield* reactor.drain; + + expect(yield* Ref.get(dispatched)).toEqual([]); + }), + ), +); diff --git a/apps/server/src/orchestration/Layers/ThreadSettlementReactor.ts b/apps/server/src/orchestration/Layers/ThreadSettlementReactor.ts index 9c8fac72e93..e25dca33f7e 100644 --- a/apps/server/src/orchestration/Layers/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadSettlementReactor.ts @@ -33,6 +33,7 @@ const RECONCILE_INTERVAL = Duration.minutes(1); const CHANGE_REQUEST_LOOKUP_TTL = Duration.minutes(2); const CHANGE_REQUEST_LOOKUP_FAILURE_TTL = Duration.seconds(20); const MAX_BRANCH_LOOKUPS_PER_RECONCILE = 20; +const CHANGE_REQUEST_LOOKUP_FAILED = Symbol("CHANGE_REQUEST_LOOKUP_FAILED"); function workspaceCwd( thread: Pick, @@ -120,7 +121,7 @@ export const makeThreadSettlementReactor = Effect.gen(function* () { cwdLength: cwd.length, branch, errorTag: error._tag, - }).pipe(Effect.as(null)), + }).pipe(Effect.as(CHANGE_REQUEST_LOOKUP_FAILED)), ), ); @@ -129,7 +130,7 @@ export const makeThreadSettlementReactor = Effect.gen(function* () { )(function* (input: { readonly thread: OrchestrationThreadShell; readonly cwd: string | undefined; - }): Effect.fn.Return { + }): Effect.fn.Return { const branch = input.thread.branch; if (branch === null || input.cwd === undefined) return null; return yield* lookupBranchChangeRequestState(input.cwd, branch); @@ -189,6 +190,9 @@ export const makeThreadSettlementReactor = Effect.gen(function* () { cwd: cwdByThreadId.get(thread.id), }).pipe( Effect.flatMap((changeRequestState) => { + // A failed lookup is unknown, not evidence that no PR exists. + // Fail closed so a transient provider outage cannot hide live work. + if (changeRequestState === CHANGE_REQUEST_LOOKUP_FAILED) return Effect.void; const reason = resolveAutomaticSettlementReason(thread, { now: nowIso, autoSettleAfterDays: settings.threadAutoSettleAfterDays, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9fd45af590a..46d30ebd14c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -194,6 +194,9 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS export const MIN_THREAD_AUTO_SETTLE_AFTER_DAYS = 1; export const MAX_THREAD_AUTO_SETTLE_AFTER_DAYS = 90; +// Replaces the former client-local sidebar setting. Its value is intentionally +// not migrated because different clients can hold conflicting preferences; +// the first server-owned value keeps the prior three-day default. export const ThreadAutoSettleAfterDays = Schema.Number.check( Schema.isBetween({ minimum: MIN_THREAD_AUTO_SETTLE_AFTER_DAYS, From c2d1810d3823d59902a2935310c84dcda8b19f38 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 5 Aug 2026 04:40:47 -0700 Subject: [PATCH 3/3] fix(server): preserve merged PR settlement --- .../OrchestrationEngineHarness.integration.ts | 2 +- .../Layers/OrchestrationReactor.test.ts | 2 +- .../Layers/OrchestrationReactor.ts | 2 +- .../Services/ThreadSettlementReactor.ts | 16 ---- .../ThreadSettlementReactor.test.ts | 32 +++++--- .../{Layers => }/ThreadSettlementReactor.ts | 73 ++++++++++--------- apps/server/src/server.ts | 4 +- 7 files changed, 66 insertions(+), 65 deletions(-) delete mode 100644 apps/server/src/orchestration/Services/ThreadSettlementReactor.ts rename apps/server/src/orchestration/{Layers => }/ThreadSettlementReactor.test.ts (86%) rename apps/server/src/orchestration/{Layers => }/ThreadSettlementReactor.ts (81%) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index ef7c12c38d3..24d1e94d68d 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -59,7 +59,7 @@ import { type OrchestrationEngineShape, } from "../src/orchestration/Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; -import { ThreadSettlementReactor } from "../src/orchestration/Services/ThreadSettlementReactor.ts"; +import { ThreadSettlementReactor } from "../src/orchestration/ThreadSettlementReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; import { diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 9d4dfcbf721..61531a27c3c 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -9,7 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; -import { ThreadSettlementReactor } from "../Services/ThreadSettlementReactor.ts"; +import { ThreadSettlementReactor } from "../ThreadSettlementReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index 4cfc1002689..9b0b375e110 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -9,7 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; -import { ThreadSettlementReactor } from "../Services/ThreadSettlementReactor.ts"; +import { ThreadSettlementReactor } from "../ThreadSettlementReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { diff --git a/apps/server/src/orchestration/Services/ThreadSettlementReactor.ts b/apps/server/src/orchestration/Services/ThreadSettlementReactor.ts deleted file mode 100644 index ce094e647dd..00000000000 --- a/apps/server/src/orchestration/Services/ThreadSettlementReactor.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; -import type * as Scope from "effect/Scope"; - -export interface ThreadSettlementReactorShape { - /** Start the persisted inactivity and merged-PR settlement lifecycle. */ - readonly start: () => Effect.Effect; - - /** Resolves when all automatic settlement work already queued is complete. */ - readonly drain: Effect.Effect; -} - -export class ThreadSettlementReactor extends Context.Service< - ThreadSettlementReactor, - ThreadSettlementReactorShape ->()("t3/orchestration/Services/ThreadSettlementReactor") {} diff --git a/apps/server/src/orchestration/Layers/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts similarity index 86% rename from apps/server/src/orchestration/Layers/ThreadSettlementReactor.test.ts rename to apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 7ddb6dd3dda..809742aeada 100644 --- a/apps/server/src/orchestration/Layers/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -18,18 +18,18 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import * as SourceControlProvider from "../../sourceControl/SourceControlProvider.ts"; -import { SourceControlProviderRegistry } from "../../sourceControl/SourceControlProviderRegistry.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import * as SourceControlProvider from "../sourceControl/SourceControlProvider.ts"; +import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape, -} from "../Services/OrchestrationEngine.ts"; +} from "./Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery, type ProjectionSnapshotQueryShape, -} from "../Services/ProjectionSnapshotQuery.ts"; -import { makeThreadSettlementReactor } from "./ThreadSettlementReactor.ts"; +} from "./Services/ProjectionSnapshotQuery.ts"; +import { make } from "./ThreadSettlementReactor.ts"; const projectId = ProjectId.make("project-1"); const project: OrchestrationProjectShell = { @@ -109,10 +109,15 @@ it.effect( branch: "feature/merged", pinned: true, }); + const pinnedMixed = makeThread({ + id: "pinned-mixed", + branch: "feature/mixed", + pinned: true, + }); const snapshot = { snapshotSequence: 1, projects: [project], - threads: [inactivity, pinnedClosed, pinnedMerged], + threads: [inactivity, pinnedClosed, pinnedMerged, pinnedMixed], updatedAt: "2020-01-02T00:00:00.000Z", } satisfies OrchestrationShellSnapshot; const dispatched = yield* Ref.make>([]); @@ -128,7 +133,12 @@ it.effect( ? [changeRequest(input.headSelector, "closed")] : input.headSelector === pinnedMerged.branch ? [changeRequest(input.headSelector, "merged")] - : [], + : input.headSelector === pinnedMixed.branch + ? [ + changeRequest(input.headSelector, "closed"), + changeRequest(input.headSelector, "merged"), + ] + : [], ), } as unknown as SourceControlProvider.SourceControlProvider["Service"]; @@ -152,7 +162,7 @@ it.effect( NodeServices.layer, ); - const reactor = yield* makeThreadSettlementReactor.pipe(Effect.provide(dependencies)); + const reactor = yield* make.pipe(Effect.provide(dependencies)); yield* reactor.start(); yield* reactor.drain; @@ -160,7 +170,7 @@ it.effect( .filter((command) => command.type === "thread.settle") .map((command) => command.threadId) .sort(); - expect(settledThreadIds).toEqual([inactivity.id, pinnedMerged.id].sort()); + expect(settledThreadIds).toEqual([inactivity.id, pinnedMerged.id, pinnedMixed.id].sort()); }), ), ); @@ -207,7 +217,7 @@ it.effect("does not settle a stale thread when its PR state lookup fails", () => NodeServices.layer, ); - const reactor = yield* makeThreadSettlementReactor.pipe(Effect.provide(dependencies)); + const reactor = yield* make.pipe(Effect.provide(dependencies)); yield* reactor.start(); yield* reactor.drain; diff --git a/apps/server/src/orchestration/Layers/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts similarity index 81% rename from apps/server/src/orchestration/Layers/ThreadSettlementReactor.ts rename to apps/server/src/orchestration/ThreadSettlementReactor.ts index e25dca33f7e..ede635319c4 100644 --- a/apps/server/src/orchestration/Layers/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -7,6 +7,7 @@ import { import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -14,20 +15,17 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; -import { forkParked } from "../../serverActivation.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { normalizeSourceBranch } from "../../sourceControl/SourceControlProvider.ts"; -import { SourceControlProviderRegistry } from "../../sourceControl/SourceControlProviderRegistry.ts"; -import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; -import { - ThreadSettlementReactor, - type ThreadSettlementReactorShape, -} from "../Services/ThreadSettlementReactor.ts"; -import { resolveAutomaticSettlementReason } from "../threadSettlement.ts"; +import { resolveThreadWorkspaceCwd } from "../checkpointing/Utils.ts"; +import { forkParked } from "../serverActivation.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { normalizeSourceBranch } from "../sourceControl/SourceControlProvider.ts"; +import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts"; +import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import { resolveAutomaticSettlementReason } from "./threadSettlement.ts"; const RECONCILE_INTERVAL = Duration.minutes(1); const CHANGE_REQUEST_LOOKUP_TTL = Duration.minutes(2); @@ -35,6 +33,17 @@ const CHANGE_REQUEST_LOOKUP_FAILURE_TTL = Duration.seconds(20); const MAX_BRANCH_LOOKUPS_PER_RECONCILE = 20; const CHANGE_REQUEST_LOOKUP_FAILED = Symbol("CHANGE_REQUEST_LOOKUP_FAILED"); +export class ThreadSettlementReactor extends Context.Service< + ThreadSettlementReactor, + { + /** Start the persisted inactivity and merged-PR settlement lifecycle. */ + readonly start: () => Effect.Effect; + + /** Resolves when all automatic settlement work already queued is complete. */ + readonly drain: Effect.Effect; + } +>()("t3/orchestration/ThreadSettlementReactor") {} + function workspaceCwd( thread: Pick, projects: ReadonlyArray, @@ -46,7 +55,7 @@ function refsMatch(left: string, right: string): boolean { return normalizeSourceBranch(left) === normalizeSourceBranch(right); } -export const makeThreadSettlementReactor = Effect.gen(function* () { +export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; @@ -72,6 +81,7 @@ export const makeThreadSettlementReactor = Effect.gen(function* () { ); return ( matching.find((changeRequest) => changeRequest.state === "open")?.state ?? + matching.find((changeRequest) => changeRequest.state === "merged")?.state ?? matching[0]?.state ?? null ); @@ -216,28 +226,25 @@ export const makeThreadSettlementReactor = Effect.gen(function* () { const worker = yield* makeDrainableWorker((_input: void) => reconcileSafely); - const start: ThreadSettlementReactorShape["start"] = Effect.fn("ThreadSettlementReactor.start")( - function* () { - yield* forkParked( - Stream.runForEach(serverSettings.streamChanges, () => worker.enqueue(undefined)), - ); - yield* worker.enqueue(undefined); - yield* forkParked( - Effect.sleep(RECONCILE_INTERVAL).pipe( - Effect.andThen(worker.enqueue(undefined)), - Effect.forever, - ), - ); - }, - ); + const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn( + "ThreadSettlementReactor.start", + )(function* () { + yield* forkParked( + Stream.runForEach(serverSettings.streamChanges, () => worker.enqueue(undefined)), + ); + yield* worker.enqueue(undefined); + yield* forkParked( + Effect.sleep(RECONCILE_INTERVAL).pipe( + Effect.andThen(worker.enqueue(undefined)), + Effect.forever, + ), + ); + }); - return { + return ThreadSettlementReactor.of({ start, drain: worker.drain, - } satisfies ThreadSettlementReactorShape; + }); }); -export const ThreadSettlementReactorLive = Layer.effect( - ThreadSettlementReactor, - makeThreadSettlementReactor, -); +export const layer = Layer.effect(ThreadSettlementReactor, make); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 7513e6772da..85ea093b364 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -55,7 +55,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; -import { ThreadSettlementReactorLive } from "./orchestration/Layers/ThreadSettlementReactor.ts"; +import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; @@ -236,7 +236,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), - Layer.provideMerge(ThreadSettlementReactorLive), + Layer.provideMerge(ThreadSettlementReactor.layer), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), );