diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 44c12cc554ad..a45936ed66ae 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -31,8 +31,6 @@ const clientSettings: ClientSettings = { glassOpacity: 80, planModeEnabled: false, providerModelPreferences: {}, - sidebarAutoSettleAfterDays: 3, - sidebarAutoSettleOnMerge: true, 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 60cb1b475569..1a8c9526df65 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -207,9 +207,6 @@ export function HomeScreen(props: HomeScreenProps) { >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -486,26 +483,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. The next partition applies the configured - // merge rule and the always-on close rule, matching 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); @@ -569,9 +546,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 @@ -579,9 +555,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); @@ -667,8 +642,6 @@ export function HomeScreen(props: HomeScreenProps) { projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, searchQuery: props.searchQuery, matchedThreadKeys, - changeRequestStateByKey, - autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, @@ -679,8 +652,6 @@ export function HomeScreen(props: HomeScreenProps) { selectedThreadKey: null, }); }, [ - changeRequestStateByKey, - autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -850,7 +821,6 @@ export function HomeScreen(props: HomeScreenProps) { onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} onMovePinnedThread={handleMovePinnedThread} - onChangeRequestState={handleChangeRequestState} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null } @@ -860,7 +830,6 @@ export function HomeScreen(props: HomeScreenProps) { ); }, [ - handleChangeRequestState, handleDeleteThread, arrangedPinnedKeys, handleMovePinnedThread, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 5c66944042ad..c0e80356d70c 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -118,9 +118,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), @@ -143,8 +142,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/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index c718558a2e66..fde9eb7a0e65 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -32,9 +32,12 @@ import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; +import { environmentCatalog } from "../../connection/catalog"; import { runtime } from "../../lib/runtime"; import { useThemeColor } from "../../lib/useThemeColor"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { type AppUpdateCheckState, @@ -522,11 +525,35 @@ function ConfiguredSettingsRouteScreen() { } function GeneralSettingsSection() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; + const environmentCatalogState = useAtomValue(environmentCatalog.catalogValueAtom); + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const updateServerSettings = useAtomCommand( + serverEnvironment.updateSettings, + "server settings update", + ); + const environmentIds = useMemo( + () => [...environmentCatalogState.entries.keys()], + [environmentCatalogState.entries], + ); + const allServerConfigsAvailable = + environmentCatalogState.isReady && + environmentIds.length > 0 && + environmentIds.every((environmentId) => serverConfigs.has(environmentId)); + const autoSettleOnMerge = environmentIds.every( + (environmentId) => serverConfigs.get(environmentId)?.settings.threadAutoSettleOnMerge !== false, + ); + const handleAutoSettleOnMergeChange = useCallback( + (value: boolean) => { + if (!allServerConfigsAvailable) return; + for (const environmentId of environmentIds) { + void updateServerSettings({ + environmentId, + input: { patch: { threadAutoSettleOnMerge: value } }, + }); + } + }, + [allServerConfigsAvailable, environmentIds, updateServerSettings], + ); return ( @@ -534,8 +561,9 @@ function GeneralSettingsSection() { savePreferences({ autoSettleOnMerge: value })} + onValueChange={handleAutoSettleOnMergeChange} /> diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 12e974fe830c..436dbf0a2f81 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -10,7 +10,6 @@ import { import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; import type { EnvironmentId } from "@t3tools/contracts"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; @@ -30,7 +29,6 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; -import { mobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; @@ -216,10 +214,6 @@ function ThreadNavigationSidebarPane( regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -417,26 +411,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. The next partition applies the configured - // merge rule and the always-on close rule. - 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( @@ -456,9 +430,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 @@ -466,9 +439,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); @@ -551,8 +523,6 @@ function ThreadNavigationSidebarPane( projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, matchedThreadKeys, - changeRequestStateByKey, - autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, @@ -563,8 +533,6 @@ function ThreadNavigationSidebarPane( selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ - changeRequestStateByKey, - autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -988,7 +956,6 @@ function ThreadNavigationSidebarPane( onPinThread={pinThread} onUnpinThread={unpinThread} onMovePinnedThread={movePinnedThread} - onChangeRequestState={handleChangeRequestState} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -1113,7 +1080,6 @@ function ThreadNavigationSidebarPane( arrangedPinnedKeys, 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 1c25f949ed7c..fbeb30d0cd02 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -369,12 +369,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly canMovePinnedDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; - /** Reports this row's live PR state for the partition's merge and close - rules. Mirrors web's onChangeRequestState. */ - readonly onChangeRequestState?: ( - threadKey: string, - state: "open" | "closed" | "merged" | null, - ) => void; readonly projectCwd?: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; @@ -397,17 +391,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onPinThread, onUnpinThread, onMovePinnedThread, - 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"); @@ -446,8 +434,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 c4a1a844c777..da886e79d05d 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"; @@ -263,21 +262,6 @@ describe("sortThreadsForListV2", () => { }); describe("buildThreadListV2Items", () => { - it("keeps a merged thread active when auto-settle on merge is off", () => { - const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" }); - const layout = buildThreadListV2Items({ - threads: [merged], - environmentId: null, - searchQuery: "", - changeRequestStateByKey: new Map([[`${environmentId}:${merged.id}`, "merged"]]), - autoSettleOnMerge: false, - now: NOW, - }); - - expect(layout.items.map((item) => item.thread.id)).toEqual(["merged"]); - expect(layout.settledCount).toBe(0); - }); - it("hides snoozed threads and counts them — visibility parity with web", () => { const layout = buildThreadListV2Items({ threads: [ @@ -307,7 +291,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" }), @@ -591,13 +575,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", () => { @@ -697,18 +696,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 53b80e52c4f1..2e05e5223793 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, @@ -147,17 +146,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 @@ -305,9 +293,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. Mobile stores these - * auto-settle preferences per device. + * 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 +305,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,8 +312,6 @@ export function buildThreadListV2Items(input: { /** Environments whose server supports thread.snooze/unsnooze. Same contract as settlementEnvironmentIds. */ readonly snoozeEnvironmentIds?: ReadonlySet; - readonly autoSettleAfterDays?: number; - readonly autoSettleOnMerge?: boolean; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; /** Injectable for tests; defaults to now. */ @@ -348,8 +331,6 @@ export function buildThreadListV2Items(input: { }): ThreadListV2Layout { const now = input.now ?? new Date().toISOString(); const snoozeNow = input.snoozeNow ?? now; - const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; - const autoSettleOnMerge = input.autoSettleOnMerge ?? true; const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) @@ -361,8 +342,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; @@ -381,8 +362,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 (and its pinOrderKey) survives underneath, so a woken @@ -398,21 +377,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, - autoSettleOnMerge, - changeRequestState, - }) - ) { + if (supportsSettlement && thread.settledAt !== null) { settled.push(thread); } else { active.push(thread); @@ -433,8 +404,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/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index b504fb190c6d..bf40acb053b7 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -26,7 +26,6 @@ export interface Preferences { /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; - readonly autoSettleOnMerge?: boolean; /** * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has * no client-settings sync, so the legacy grouped thread list is opted into @@ -86,7 +85,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; - autoSettleOnMerge?: boolean; legacyThreadListEnabled?: boolean; } = {}; @@ -124,9 +122,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.projectGroupingMode = parsed.projectGroupingMode; } - if (typeof parsed.autoSettleOnMerge === "boolean") { - preferences.autoSettleOnMerge = parsed.autoSettleOnMerge; - } if (typeof parsed.legacyThreadListEnabled === "boolean") { preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; } diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 71ef59a0910c..946eb49f6c0a 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -63,6 +63,7 @@ import { type OrchestrationEngineShape, } from "../src/orchestration/Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.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 { @@ -342,6 +343,8 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge( Layer.succeed(VcsStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), + peekStatus: () => Effect.die("peekStatus should not be called in this test"), + pollStatus: () => Effect.die("pollStatus should not be called in this test"), refreshLocalStatus: () => Effect.succeed({ isRepo: true, @@ -375,6 +378,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/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 08ea1437bb29..efa67a2a0803 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -316,6 +316,8 @@ describe("CheckpointReactor", () => { }); const vcsStatusBroadcasterLayer = Layer.succeed(VcsStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), + peekStatus: () => Effect.die("peekStatus should not be called in this test"), + pollStatus: () => Effect.die("pollStatus should not be called in this test"), refreshLocalStatus: (cwd: string) => Effect.sync(() => { options?.gitStatusRefreshCalls?.push(cwd); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 300d1526bb9a..61531a27c3cb 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 "../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 fb7543e31af0..9b0b375e110d 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 "../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/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2b4d3771605a..217c7d71f02f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -400,6 +400,8 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge( Layer.succeed(VcsStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), + peekStatus: () => Effect.die("peekStatus should not be called in this test"), + pollStatus: () => Effect.die("pollStatus should not be called in this test"), refreshLocalStatus: () => Effect.die("refreshLocalStatus should not be called in this test"), refreshStatus, diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts new file mode 100644 index 000000000000..a6dce9154061 --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -0,0 +1,344 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + GitManagerError, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThreadShell, + type VcsStatusResult, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "./Services/OrchestrationEngine.ts"; +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "./Services/ProjectionSnapshotQuery.ts"; +import { make } 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 | null; + readonly cwd?: 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 ?? null, + worktreePath: input.cwd ?? 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 vcsStatus( + branch: string, + state: "open" | "closed" | "merged" | null, + headRef = branch, +): VcsStatusResult { + return { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: branch, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: + state === null + ? null + : { + number: 1, + title: branch, + url: `https://example.test/${branch}`, + baseRef: "main", + headRef, + state, + }, + }; +} + +function makeHarness(input: { + readonly threads: ReadonlyArray; + readonly peekStatus?: VcsStatusBroadcaster["Service"]["peekStatus"]; + readonly pollStatus?: VcsStatusBroadcaster["Service"]["pollStatus"]; + readonly opportunisticWork?: boolean; + readonly autoSettleOnMerge?: boolean; +}) { + return Effect.gen(function* () { + const snapshot = { + snapshotSequence: 1, + projects: [project], + threads: input.threads, + updatedAt: "2020-01-02T00:00:00.000Z", + } satisfies OrchestrationShellSnapshot; + const dispatched = yield* Ref.make>([]); + 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({ + threadAutoSettleOnMerge: input.autoSettleOnMerge ?? true, + }), + Layer.succeed(VcsStatusBroadcaster, { + getStatus: () => Effect.die("getStatus should not be called"), + peekStatus: input.peekStatus ?? (() => Effect.succeed(null)), + pollStatus: input.pollStatus ?? (() => Effect.die("pollStatus should not be called")), + refreshLocalStatus: () => Effect.die("refreshLocalStatus should not be called"), + refreshStatus: () => Effect.die("refreshStatus should not be called"), + streamStatus: () => Stream.empty, + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunOpportunisticWork: Effect.succeed(input.opportunisticWork ?? false), + }), + NodeServices.layer, + ); + const reactor = yield* make.pipe(Effect.provide(dependencies)); + return { reactor, dispatched }; + }); +} + +it.effect("persists inactivity and merged-PR settlement while leaving other pins alone", () => + Effect.scoped( + Effect.gen(function* () { + const inactivity = makeThread({ id: "inactivity" }); + const pinnedClosed = makeThread({ + id: "pinned-closed", + branch: "feature/closed", + cwd: "/repo/closed", + pinned: true, + }); + const pinnedOpen = makeThread({ + id: "pinned-open", + branch: "feature/open", + cwd: "/repo/open", + pinned: true, + }); + const pinnedMerged = makeThread({ + id: "pinned-merged", + branch: "feature/merged", + cwd: "/repo/merged", + pinned: true, + }); + const byCwd = new Map([ + [pinnedClosed.worktreePath, vcsStatus(pinnedClosed.branch!, "closed")], + [pinnedOpen.worktreePath, vcsStatus(pinnedOpen.branch!, "open")], + [pinnedMerged.worktreePath, vcsStatus(pinnedMerged.branch!, "merged")], + ]); + const { reactor, dispatched } = yield* makeHarness({ + threads: [inactivity, pinnedClosed, pinnedOpen, pinnedMerged], + peekStatus: ({ cwd }) => Effect.succeed(byCwd.get(cwd) ?? null), + pollStatus: (cwd) => Effect.succeed(byCwd.get(cwd)!), + opportunisticWork: true, + }); + + 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()); + }), + ), +); + +it.effect("re-verifies cached PR state and settles a pin only after merge", () => + Effect.scoped( + Effect.gen(function* () { + const thread = makeThread({ + id: "pinned-merged-after-cache", + branch: "feature/merge-later", + cwd: "/repo/merge-later", + pinned: true, + }); + const pollCalls = yield* Ref.make(0); + const { reactor, dispatched } = yield* makeHarness({ + threads: [thread], + peekStatus: () => Effect.succeed(vcsStatus(thread.branch!, "open")), + pollStatus: () => + Ref.update(pollCalls, (count) => count + 1).pipe( + Effect.as(vcsStatus(thread.branch!, "merged")), + ), + opportunisticWork: true, + }); + + yield* reactor.start(); + yield* reactor.drain; + + expect(yield* Ref.get(pollCalls)).toBe(1); + expect( + (yield* Ref.get(dispatched)).map((command) => + command.type === "thread.settle" ? command.threadId : null, + ), + ).toEqual([thread.id]); + }), + ), +); + +it.effect("keeps merged pinned threads active when merge settlement is disabled", () => + Effect.scoped( + Effect.gen(function* () { + const thread = makeThread({ + id: "pinned-merged-disabled", + branch: "feature/merged-disabled", + cwd: "/repo/merged-disabled", + pinned: true, + }); + const pollCalls = yield* Ref.make(0); + const { reactor, dispatched } = yield* makeHarness({ + threads: [thread], + peekStatus: () => Effect.succeed(vcsStatus(thread.branch!, "open")), + pollStatus: () => + Ref.update(pollCalls, (count) => count + 1).pipe( + Effect.as(vcsStatus(thread.branch!, "merged")), + ), + opportunisticWork: true, + autoSettleOnMerge: false, + }); + + yield* reactor.start(); + yield* reactor.drain; + + expect(yield* Ref.get(pollCalls)).toBe(0); + expect(yield* Ref.get(dispatched)).toEqual([]); + }), + ), +); + +it.effect("does not settle when PR verification fails or belongs to another branch", () => + Effect.scoped( + Effect.gen(function* () { + const lookupFailed = makeThread({ + id: "lookup-failed", + branch: "feature/open-pr", + cwd: "/repo/lookup-failed", + }); + const mismatchedMerge = makeThread({ + id: "mismatched-merge", + branch: "feature/current", + cwd: "/repo/mismatched", + }); + const { reactor, dispatched } = yield* makeHarness({ + threads: [lookupFailed, mismatchedMerge], + peekStatus: ({ cwd }) => + Effect.succeed( + cwd === mismatchedMerge.worktreePath + ? vcsStatus(mismatchedMerge.branch!, "merged", "feature/other") + : null, + ), + pollStatus: (cwd) => + cwd === lookupFailed.worktreePath + ? Effect.fail( + new GitManagerError({ + operation: "ThreadSettlementReactor.test", + cwd, + detail: "temporary lookup failure", + }), + ) + : Effect.succeed(vcsStatus(mismatchedMerge.branch!, "merged", "feature/other")), + opportunisticWork: true, + }); + + yield* reactor.start(); + yield* reactor.drain; + + expect(yield* Ref.get(dispatched)).toEqual([]); + }), + ), +); + +it.effect("bounds live verification and lets cooldown-skipped entries yield the next batch", () => + Effect.scoped( + Effect.gen(function* () { + const threads = Array.from({ length: 7 }, (_, index) => + makeThread({ + id: `pinned-${index}`, + branch: `feature/${index}`, + cwd: `/repo/${index}`, + pinned: true, + }), + ); + const branchByCwd = new Map(threads.map((thread) => [thread.worktreePath, thread.branch!])); + const polledCwds = yield* Ref.make>([]); + const { reactor, dispatched } = yield* makeHarness({ + threads, + pollStatus: (cwd) => + Ref.update(polledCwds, (values) => [...values, cwd]).pipe( + Effect.as(vcsStatus(branchByCwd.get(cwd)!, "open")), + ), + opportunisticWork: true, + }); + + yield* reactor.start(); + yield* reactor.drain; + expect(yield* Ref.get(polledCwds)).toHaveLength(5); + + yield* reactor.start(); + yield* reactor.drain; + expect(new Set(yield* Ref.get(polledCwds))).toEqual(new Set(branchByCwd.keys())); + expect(yield* Ref.get(dispatched)).toEqual([]); + }), + ), +); diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts new file mode 100644 index 000000000000..a34785f76921 --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -0,0 +1,225 @@ +import { + CommandId, + type OrchestrationProjectShell, + type OrchestrationThreadShell, + type VcsStatusResult, +} from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +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 Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import { resolveThreadWorkspaceCwd } from "../checkpointing/Utils.ts"; +import { forkParked } from "../serverActivation.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; +import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import { + type AutomaticSettlementChangeRequestState, + resolveAutomaticSettlementVerdict, +} from "./threadSettlement.ts"; + +const RECONCILE_INTERVAL = Duration.minutes(1); +const PR_VERIFY_COOLDOWN_MS = Duration.toMillis(Duration.minutes(30)); +const MAX_PR_VERIFICATIONS_PER_RECONCILE = 5; + +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, +): string | undefined { + return resolveThreadWorkspaceCwd({ thread, projects }); +} + +function cachedChangeRequestState( + thread: Pick, + status: VcsStatusResult | null, +): AutomaticSettlementChangeRequestState { + if (thread.branch === null) return null; + if (status === null || status.refName !== thread.branch) return "unknown"; + if (status.pr === null || status.pr.headRef !== thread.branch) return "unknown"; + if (status.pr.state === "open") return "open-cached"; + if (status.pr.state === "closed") return "closed-cached"; + return "merged"; +} + +function liveChangeRequestState( + thread: Pick, + status: VcsStatusResult, +): AutomaticSettlementChangeRequestState { + if (thread.branch === null) return null; + if (status.refName !== thread.branch || status.pr?.headRef !== thread.branch) return "unknown"; + return status.pr.state; +} + +export const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const orchestrationEngine = yield* OrchestrationEngineService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const serverSettings = yield* ServerSettingsService; + const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; + const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; + const lastPrVerifyAtByCwd = yield* Ref.make(new Map()); + + 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 verifyChangeRequestState = Effect.fn("ThreadSettlementReactor.verifyChangeRequestState")( + function* (input: { + readonly thread: OrchestrationThreadShell; + readonly cwd: string; + }): Effect.fn.Return<{ + readonly state: AutomaticSettlementChangeRequestState; + readonly verified: boolean; + }> { + const now = yield* Clock.currentTimeMillis; + const mayVerify = yield* Ref.modify(lastPrVerifyAtByCwd, (byCwd) => { + const lastAt = byCwd.get(input.cwd); + if (lastAt !== undefined && now - lastAt < PR_VERIFY_COOLDOWN_MS) { + return [false, byCwd] as const; + } + const next = new Map(byCwd); + next.set(input.cwd, now); + return [true, next] as const; + }); + if (!mayVerify) return { state: "unknown", verified: false }; + + const status = yield* vcsStatusBroadcaster.pollStatus(input.cwd).pipe( + Effect.catch((error) => + Effect.logDebug("automatic thread settlement could not verify change request state", { + threadId: input.thread.id, + cwdLength: input.cwd.length, + errorTag: error._tag, + }).pipe(Effect.as(null)), + ), + ); + return status === null + ? { state: "unknown", verified: true } + : { state: liveChangeRequestState(input.thread, status), verified: true }; + }, + ); + + const reconcile = Effect.fn("ThreadSettlementReactor.reconcile")(function* () { + const [snapshot, settings, now, mayVerifyPr] = yield* Effect.all([ + projectionSnapshotQuery.getShellSnapshot(), + serverSettings.getSettings, + DateTime.now, + backgroundPolicy.shouldRunOpportunisticWork, + ]); + const nowIso = DateTime.formatIso(now); + let verifyBudget = MAX_PR_VERIFICATIONS_PER_RECONCILE; + + for (const thread of snapshot.threads) { + const cwd = workspaceCwd(thread, snapshot.projects); + const changeRequestState = + thread.branch === null + ? null + : cwd === undefined + ? "unknown" + : cachedChangeRequestState(thread, yield* vcsStatusBroadcaster.peekStatus({ cwd })); + const verdict = resolveAutomaticSettlementVerdict(thread, { + now: nowIso, + autoSettleAfterDays: settings.threadAutoSettleAfterDays, + autoSettleOnMerge: settings.threadAutoSettleOnMerge, + changeRequestState, + }); + if (verdict.kind === "skip") continue; + if (verdict.kind === "settle") { + yield* dispatchSettlementSafely(thread, verdict.reason); + continue; + } + if (!mayVerifyPr || cwd === undefined || verifyBudget <= 0) continue; + + const verification = yield* verifyChangeRequestState({ thread, cwd }); + if (verification.verified) verifyBudget -= 1; + const verifiedVerdict = resolveAutomaticSettlementVerdict(thread, { + now: nowIso, + autoSettleAfterDays: settings.threadAutoSettleAfterDays, + autoSettleOnMerge: settings.threadAutoSettleOnMerge, + changeRequestState: verification.state, + }); + if (verifiedVerdict.kind === "settle") { + yield* dispatchSettlementSafely(thread, verifiedVerdict.reason); + } + } + }); + + 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: 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 ThreadSettlementReactor.of({ + start, + drain: worker.drain, + }); +}); + +export const layer = Layer.effect(ThreadSettlementReactor, make); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index a48bb29e154b..e85a4c291b10 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; /** @@ -456,9 +456,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 000000000000..dd31f541d819 --- /dev/null +++ b/apps/server/src/orchestration/threadSettlement.test.ts @@ -0,0 +1,261 @@ +import { + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + resolveAutomaticSettlementReason, + resolveAutomaticSettlementVerdict, + 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 autoSettleOnMerge?: boolean; + readonly now?: string; + } = {}, +) { + return resolveAutomaticSettlementReason(shell, { + now: options.now ?? NOW, + autoSettleAfterDays: + options.autoSettleAfterDays === undefined ? 3 : options.autoSettleAfterDays, + autoSettleOnMerge: options.autoSettleOnMerge ?? true, + 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"); + expect(resolve(pinned, { changeRequestState: "merged", autoSettleOnMerge: false })).toBeNull(); + }); + + 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("resolveAutomaticSettlementVerdict", () => { + it("re-verifies cached or unknown PR state even for pinned threads", () => { + const pinned = makeShell({ + activityAt: FRESH, + pinnedAt: "2026-04-07T00:00:00.000Z", + }); + + for (const changeRequestState of ["unknown", "open-cached", "closed-cached"] as const) { + expect( + resolveAutomaticSettlementVerdict(pinned, { + now: NOW, + autoSettleAfterDays: 3, + autoSettleOnMerge: true, + changeRequestState, + }), + ).toEqual({ kind: "verify-pr" }); + } + }); + + it("does not look up PR state for pins when merge settlement is disabled", () => { + const pinned = makeShell({ + activityAt: FRESH, + pinnedAt: "2026-04-07T00:00:00.000Z", + }); + + expect( + resolveAutomaticSettlementVerdict(pinned, { + now: NOW, + autoSettleAfterDays: 3, + autoSettleOnMerge: false, + changeRequestState: "open-cached", + }), + ).toEqual({ kind: "skip" }); + }); + + it("does not re-verify terminal live states", () => { + const pinned = makeShell({ + activityAt: STALE, + pinnedAt: "2026-04-07T00:00:00.000Z", + }); + + expect( + resolveAutomaticSettlementVerdict(pinned, { + now: NOW, + autoSettleAfterDays: 3, + autoSettleOnMerge: true, + changeRequestState: "closed", + }), + ).toEqual({ kind: "skip" }); + expect( + resolveAutomaticSettlementVerdict(pinned, { + now: NOW, + autoSettleAfterDays: 3, + autoSettleOnMerge: true, + changeRequestState: "merged", + }), + ).toEqual({ kind: "settle", reason: "pr-merged" }); + expect( + resolveAutomaticSettlementVerdict(pinned, { + now: NOW, + autoSettleAfterDays: 3, + autoSettleOnMerge: false, + changeRequestState: "merged", + }), + ).toEqual({ kind: "skip" }); + }); +}); + +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 000000000000..13a1a75b6ed1 --- /dev/null +++ b/apps/server/src/orchestration/threadSettlement.ts @@ -0,0 +1,121 @@ +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 type AutomaticSettlementChangeRequestState = + | ChangeRequestState + | "open-cached" + | "closed-cached" + | "unknown" + | null; +export type AutomaticSettlementVerdict = + | { readonly kind: "skip" } + | { readonly kind: "verify-pr" } + | { readonly kind: "settle"; readonly reason: AutomaticSettlementReason }; + +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 resolveAutomaticSettlementVerdict( + shell: OrchestrationThreadShell, + options: { + readonly now: string; + readonly autoSettleAfterDays: ThreadAutoSettleAfterDays | null; + readonly autoSettleOnMerge: boolean; + readonly changeRequestState: AutomaticSettlementChangeRequestState; + }, +): AutomaticSettlementVerdict { + if (shell.settledOverride !== null) return { kind: "skip" }; + if (shell.hasPendingApprovals || shell.hasPendingUserInput) return { kind: "skip" }; + if (shell.session?.status === "starting" || shell.session?.status === "running") { + return { kind: "skip" }; + } + if (shellHasQueuedTurnStart(shell, options.now)) return { kind: "skip" }; + + if (shell.pinnedAt != null && !options.autoSettleOnMerge) return { kind: "skip" }; + if (options.changeRequestState === "merged" && options.autoSettleOnMerge) { + return { kind: "settle", reason: "pr-merged" }; + } + // Cached non-terminal states can hide a later merge. Re-verify them on the + // reactor's bounded cooldown even for fresh or pinned threads. + if ( + options.changeRequestState === "unknown" || + options.changeRequestState === "open-cached" || + options.changeRequestState === "closed-cached" + ) { + return { kind: "verify-pr" }; + } + if (shell.pinnedAt != null) return { kind: "skip" }; + if (options.changeRequestState === "open") return { kind: "skip" }; + if (options.autoSettleAfterDays === null) return { kind: "skip" }; + + const lastActivityAt = threadLastActivityAt(shell); + if (lastActivityAt === null) return { kind: "skip" }; + return Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS + ? { kind: "settle", reason: "inactivity" } + : { kind: "skip" }; +} + +export function resolveAutomaticSettlementReason( + shell: OrchestrationThreadShell, + options: { + readonly now: string; + readonly autoSettleAfterDays: ThreadAutoSettleAfterDays | null; + readonly autoSettleOnMerge: boolean; + readonly changeRequestState: ChangeRequestState | null; + }, +): AutomaticSettlementReason | null { + const verdict = resolveAutomaticSettlementVerdict(shell, options); + return verdict.kind === "settle" ? verdict.reason : null; +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 32bcaaa8b96b..c90a55fac49e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -58,6 +58,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 * 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"; @@ -242,6 +243,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), + Layer.provideMerge(ThreadSettlementReactor.layer), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 6820a29e2c86..bc5828ead541 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -7,6 +7,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; @@ -74,7 +75,10 @@ function makeTestLayer(state: { remoteStatusCalls: number; localInvalidationCalls: number; remoteInvalidationCalls: number; + fullInvalidationCalls?: number; remoteStatusRefreshUpstreamValues?: Array; + localStatusEffect?: (call: number) => Effect.Effect; + remoteStatusEffect?: (call: number) => Effect.Effect; }) { return VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), @@ -82,15 +86,21 @@ function makeTestLayer(state: { Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => - Effect.sync(() => { + Effect.suspend(() => { state.localStatusCalls += 1; - return state.currentLocalStatus; + return ( + state.localStatusEffect?.(state.localStatusCalls) ?? + Effect.succeed(state.currentLocalStatus) + ); }), remoteStatus: (_input, options) => - Effect.sync(() => { + Effect.suspend(() => { state.remoteStatusCalls += 1; state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream); - return state.currentRemoteStatus; + return ( + state.remoteStatusEffect?.(state.remoteStatusCalls) ?? + Effect.succeed(state.currentRemoteStatus) + ); }), invalidateLocalStatus: () => Effect.sync(() => { @@ -104,6 +114,9 @@ function makeTestLayer(state: { Effect.sync(() => { state.localInvalidationCalls += 1; state.remoteInvalidationCalls += 1; + if (state.fullInvalidationCalls !== undefined) { + state.fullInvalidationCalls += 1; + } }), }), ), @@ -167,6 +180,84 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(makeTestLayer(state))); }); + it.effect("peeks without loading and polls without invalidating the PR cache", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + fullInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + + assert.equal(yield* broadcaster.peekStatus({ cwd: "/repo" }), null); + assert.equal(state.localStatusCalls, 0); + assert.equal(state.remoteStatusCalls, 0); + + yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.deepStrictEqual(yield* broadcaster.peekStatus({ cwd: "/repo" }), baseStatus); + assert.equal(state.localStatusCalls, 1); + assert.equal(state.remoteStatusCalls, 1); + + state.currentRemoteStatus = { ...baseRemoteStatus, aheadCount: 2 }; + assert.deepStrictEqual(yield* broadcaster.pollStatus("/repo"), { + ...baseLocalStatus, + ...state.currentRemoteStatus, + }); + assert.equal(state.localInvalidationCalls, 1); + assert.equal(state.remoteInvalidationCalls, 1); + assert.equal(state.fullInvalidationCalls, 0); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("serializes full polling with explicit local refreshes", () => + Effect.gen(function* () { + const pollReadStarted = yield* Deferred.make(); + const releasePollRead = yield* Deferred.make(); + const refreshedLocalStatus = { + ...baseLocalStatus, + refName: "feature/refreshed-after-poll", + }; + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + localStatusEffect: (call: number) => + call === 1 + ? Deferred.succeed(pollReadStarted, undefined).pipe( + Effect.andThen(Deferred.await(releasePollRead)), + Effect.as(baseLocalStatus), + ) + : Effect.succeed(refreshedLocalStatus), + }; + + yield* Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const pollFiber = yield* broadcaster.pollStatus("/repo").pipe(Effect.forkChild); + yield* Deferred.await(pollReadStarted); + const refreshFiber = yield* broadcaster.refreshLocalStatus("/repo").pipe(Effect.forkChild); + yield* Effect.yieldNow; + + assert.equal(state.localStatusCalls, 1); + yield* Deferred.succeed(releasePollRead, undefined); + yield* Fiber.join(pollFiber); + yield* Fiber.join(refreshFiber); + + assert.deepStrictEqual(yield* broadcaster.peekStatus({ cwd: "/repo" }), { + ...refreshedLocalStatus, + ...baseRemoteStatus, + }); + }).pipe(Effect.provide(makeTestLayer(state))); + }), + ); + it.effect("refreshes the cached snapshot after explicit invalidation", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index f28069f6d8b2..1389d28b6be0 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -10,6 +10,7 @@ import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; import type { @@ -159,6 +160,10 @@ export class VcsStatusBroadcaster extends Context.Service< readonly getStatus: ( input: VcsStatusInput, ) => Effect.Effect; + /** Read the merged cached status without touching git or a forge. */ + readonly peekStatus: (input: VcsStatusInput) => Effect.Effect; + /** Refresh local and remote status while preserving the slower PR lookup cache. */ + readonly pollStatus: (cwd: string) => Effect.Effect; readonly refreshLocalStatus: ( cwd: string, ) => Effect.Effect; @@ -193,6 +198,22 @@ export const make = Effect.gen(function* () { ); const cacheRef = yield* Ref.make(new Map()); const pollersRef = yield* SynchronizedRef.make(new Map()); + const refreshLocksRef = yield* SynchronizedRef.make(new Map()); + + const getRefreshLock = (cwd: string) => + SynchronizedRef.modifyEffect(refreshLocksRef, (current) => { + const existing = current.get(cwd); + if (existing !== undefined) return Effect.succeed([existing, current] as const); + return Semaphore.make(1).pipe( + Effect.map((lock) => [lock, new Map(current).set(cwd, lock)] as const), + ); + }); + + const withRefreshLock = ( + cwd: string, + effect: Effect.Effect, + ): Effect.Effect => + Effect.flatMap(getRefreshLock(cwd), (lock) => lock.withPermits(1)(effect)); const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( cwd: string, @@ -329,14 +350,54 @@ export const make = Effect.gen(function* () { if (cached?.local && cached.remote) { return mergeGitStatusParts(cached.local.value, cached.remote.value); } - const [local, remote] = yield* Effect.all( - [ - cached?.local ? Effect.succeed(cached.local.value) : workflow.localStatus({ cwd }), - cached?.remote ? Effect.succeed(cached.remote.value) : workflow.remoteStatus({ cwd }), - ], - { concurrency: "unbounded" }, + return yield* withRefreshLock( + cwd, + Effect.gen(function* () { + const latest = yield* getCachedStatus(cwd); + if (latest?.local && latest.remote) { + return mergeGitStatusParts(latest.local.value, latest.remote.value); + } + const [local, remote] = yield* Effect.all( + [ + latest?.local ? Effect.succeed(latest.local.value) : workflow.localStatus({ cwd }), + latest?.remote ? Effect.succeed(latest.remote.value) : workflow.remoteStatus({ cwd }), + ], + { concurrency: "unbounded" }, + ); + return yield* updateCachedStatus(cwd, local, remote); + }), + ); + }); + + const peekStatus: VcsStatusBroadcaster["Service"]["peekStatus"] = Effect.fn( + "VcsStatusBroadcaster.peekStatus", + )(function* (input) { + const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + const cached = yield* getCachedStatus(cwd); + if (!cached?.local || !cached.remote) return null; + return mergeGitStatusParts(cached.local.value, cached.remote.value); + }); + + const pollStatus: VcsStatusBroadcaster["Service"]["pollStatus"] = Effect.fn( + "VcsStatusBroadcaster.pollStatus", + )(function* (rawCwd) { + const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + return yield* withRefreshLock( + cwd, + Effect.gen(function* () { + // Automatic polling refreshes the one-second status caches but deliberately + // leaves GitManager's slower PR cache and failure backoff intact. + yield* Effect.all( + [workflow.invalidateLocalStatus(cwd), workflow.invalidateRemoteStatus(cwd)], + { concurrency: "unbounded" }, + ); + const [local, remote] = yield* Effect.all( + [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], + { concurrency: "unbounded" }, + ); + return yield* updateCachedStatus(cwd, local, remote, { publish: true }); + }), ); - return yield* updateCachedStatus(cwd, local, remote); }); const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( @@ -351,32 +412,42 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.refreshLocalStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - return yield* refreshLocalStatusCore(cwd); + return yield* withRefreshLock(cwd, refreshLocalStatusCore(cwd)); }); const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( cwd: string, options?: { readonly refreshUpstream?: boolean }, ) { - if (options?.refreshUpstream !== false) { - yield* workflow.invalidateRemoteStatus(cwd); - } - const remote = yield* workflow.remoteStatus({ cwd }, options); - return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + return yield* withRefreshLock( + cwd, + Effect.gen(function* () { + if (options?.refreshUpstream !== false) { + yield* workflow.invalidateRemoteStatus(cwd); + } + const remote = yield* workflow.remoteStatus({ cwd }, options); + return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + }), + ); }); const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - // invalidateStatus (not the two partial invalidations) so an explicit - // refresh also bypasses GitManager's slow PR-lookup cache. - yield* workflow.invalidateStatus(cwd); - const [local, remote] = yield* Effect.all( - [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], - { concurrency: "unbounded" }, + return yield* withRefreshLock( + cwd, + Effect.gen(function* () { + // invalidateStatus (not the two partial invalidations) so an explicit + // refresh also bypasses GitManager's slow PR-lookup cache. + yield* workflow.invalidateStatus(cwd); + const [local, remote] = yield* Effect.all( + [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], + { concurrency: "unbounded" }, + ); + return yield* updateCachedStatus(cwd, local, remote, { publish: true }); + }), ); - return yield* updateCachedStatus(cwd, local, remote, { publish: true }); }); const makeRemoteRefreshLoop = ( @@ -587,6 +658,8 @@ export const make = Effect.gen(function* () { return VcsStatusBroadcaster.of({ getStatus, + peekStatus, + pollStatus, refreshLocalStatus, refreshStatus, streamStatus, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fdc7e7dee382..6716d1efbdcd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,12 +26,7 @@ import { connectionStatusTitle, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; -import { - changeRequestAutoSettles, - effectiveSettled, - effectiveSnoozed, - threadWokeAt, -} from "@t3tools/client-runtime/state/thread-settled"; +import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { parseScopedThreadKey, scopedThreadKey, @@ -186,7 +181,6 @@ import { useClientSettingsHydrated, useEnvironmentSettings, } from "../hooks/useSettings"; -import { useNowMinute } from "../hooks/useNowMinute"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; @@ -4097,12 +4091,9 @@ 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 autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); const activeThreadPr = resolveThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, @@ -4117,7 +4108,6 @@ function ChatViewContent(props: ChatViewProps) { supportsPullRequests && activeThreadPr !== null && threadRepository !== null; const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; - const nowMinute = useNowMinute(); const snoozeNow = new Date().toISOString(); const activeThreadSnoozed = activeThreadShell !== null && @@ -4150,7 +4140,7 @@ function ChatViewContent(props: ChatViewProps) { ); const activeThreadWokeVisible = useMemo(() => { if (activeThreadWokeAt === null) return false; - if (changeRequestAutoSettles(activeThreadPr?.state, autoSettleOnMerge)) return false; + if (activeThreadShell?.settledAt != null) return false; const wokeAtMs = Date.parse(activeThreadWokeAt); if (Number.isNaN(wokeAtMs)) return false; // Having the thread open counts as a visit at completedAt (the effect @@ -4170,26 +4160,11 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeLatestTurn?.completedAt, activeThreadLastVisitedAt, - activeThreadPr?.state, + activeThreadShell?.settledAt, activeThreadWokeAt, - autoSettleOnMerge, - ]); - const activeThreadSettled = useMemo(() => { - if (activeThreadShell === null || !supportsSettlement) return false; - return effectiveSettled(activeThreadShell, { - now: `${nowMinute}:00.000Z`, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequestState: activeThreadPr?.state ?? null, - }); - }, [ - activeThreadPr?.state, - activeThreadShell, - autoSettleAfterDays, - autoSettleOnMerge, - nowMinute, - supportsSettlement, ]); + const activeThreadSettled = + activeThreadShell !== null && supportsSettlement && activeThreadShell.settledAt !== null; const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false, }); @@ -6177,7 +6152,6 @@ function ChatViewContent(props: ChatViewProps) { {...(routeKind === "draft" && draftId ? { draftId } : {})} activeThreadTitle={activeThread.title} isServerThread={isServerThread} - changeRequestState={activeThreadPr?.state ?? null} activeProjectName={activeProject?.title} activeProjectCwd={activeProject?.workspaceRoot ?? null} activeProjectFaviconPath={activeProject?.faviconPath ?? null} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index c6e113a44523..7da1a1bcac3b 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -912,18 +912,9 @@ describe("sortPinnedThreadsForSidebar", () => { }); describe("sortSettledThreadsForSidebar", () => { - 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", () => { @@ -931,44 +922,16 @@ describe("sortSettledThreadsForSidebar", () => { 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 = sortSettledThreadsForSidebar([ - 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 = sortSettledThreadsForSidebar([ - 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 = sortSettledThreadsForSidebar([ 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 9cb09219df09..2eb96303b4b3 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -568,35 +568,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/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 6b44479b0cb5..41f521b44ee1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -19,8 +19,6 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- import { CSS } from "@dnd-kit/utilities"; import { canSnooze, - changeRequestAutoSettles, - effectiveSettled, effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; @@ -103,7 +101,6 @@ import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; -import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; @@ -658,7 +655,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; - autoSettleOnMerge: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: boolean; // Renders the pin glyph. Pinned cards keep the full settle/snooze quick @@ -704,11 +700,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onUnsnooze: (threadRef: ScopedThreadRef) => void; onUnpin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; - onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; }) { const { isRenaming, - onChangeRequestState, onCancelRename, onCommitRename, onContextMenu, @@ -757,7 +751,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { threadBranch: thread.branch, gitStatus: gitStatus.data, }); - const prState = pr?.state ?? null; // Same semantics as the legacy sidebar (never-visited counts as read): // switching sidebars must not light up every historical thread as unread. @@ -775,7 +768,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && - !changeRequestAutoSettles(prState, props.autoSettleOnMerge); + thread.settledAt === null; // In-flight rows (working, or waiting on approval/input) fade as a whole: // there is nothing for the user to do yet, so prominence is reserved for // rows that need a human — done (unread), read-but-unsettled, failed, and @@ -851,12 +844,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; - // Report the PR state so the parent can apply the configured merge rule - // and the always-on close rule during partitioning. - 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; @@ -1598,8 +1585,6 @@ export default function Sidebar() { const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); - const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); @@ -1792,37 +1777,12 @@ export default function Sidebar() { [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 // below, after the partition knows the boundary); the partition reads a // fresh clock whenever it recomputes. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - - // PR states stream in per-row. The next partition applies the configured - // merge rule and the always-on close rule. - 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); @@ -1910,7 +1870,6 @@ export default function Sidebar() { 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 @@ -1936,8 +1895,6 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === 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 — // and so does its pinOrderKey, so on wake the thread reappears at @@ -1952,15 +1909,7 @@ export default function Sidebar() { // arise from stale or raced writes.) } else if (thread.pinnedAt != null) { pinned.push(thread); - } else if ( - supportsSettlement && - effectiveSettled(thread, { - now, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequestState, - }) - ) { + } else if (supportsSettlement && thread.settledAt !== null) { settled.push(thread); } else { active.push(thread); @@ -1992,16 +1941,7 @@ export default function Sidebar() { settledThreads: sortSettledThreadsForSidebar(settled), snoozeNow: preciseNow, }; - }, [ - autoSettleAfterDays, - autoSettleOnMerge, - changeRequestStateByKey, - nowMinute, - scopedProjectKeys, - serverConfigs, - snoozeWakeTick, - threads, - ]); + }, [scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); @@ -3539,7 +3479,6 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSettlement === true } - autoSettleOnMerge={autoSettleOnMerge} snoozeSupported={ serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSnooze === true @@ -3597,7 +3536,6 @@ export default function Sidebar() { onUnsnooze={attemptUnsnooze} onUnpin={attemptUnpin} onAcknowledgeWoke={acknowledgeWoke} - onChangeRequestState={handleChangeRequestState} /> ); }; diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index db13419962f0..99539a73e0f7 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -10,7 +10,6 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { ChangeRequestStateLike } from "@t3tools/client-runtime/state/thread-settled"; import { ChevronDownIcon } from "lucide-react"; import { memo, @@ -50,8 +49,6 @@ interface ChatHeaderProps { activeThreadTitle: string; /** Drafts have no server thread yet, so the title carries no action menu. */ isServerThread: boolean; - /** PR state feeding the settled classification, resolved by ChatView. */ - changeRequestState: ChangeRequestStateLike | null; activeProjectName: string | undefined; activeProjectCwd: string | null; activeProjectFaviconPath: string | null; @@ -105,7 +102,6 @@ export const ChatHeader = memo(function ChatHeader({ draftId, activeThreadTitle, isServerThread, - changeRequestState, activeProjectName, activeProjectCwd, activeProjectFaviconPath, @@ -181,7 +177,6 @@ export const ChatHeader = memo(function ChatHeader({ const { openMenu } = useThreadActionMenu({ threadRef: isServerThread ? activeThreadRef : null, projectCwd: activeProjectCwd, - changeRequestState, onStartRename: startRename, }); const titleButtonRef = useRef(null); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9df7f88ab1dd..5d0c54d9db96 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -18,19 +18,20 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, + DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS, DEFAULT_UNIFIED_SETTINGS, type EnvironmentIdentificationMode, MAX_CODE_FONT_SIZE, MAX_GLASS_OPACITY, MAX_INTERFACE_FONT_SIZE, MAX_PROMPT_FONT_SIZE, - MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + MAX_THREAD_AUTO_SETTLE_AFTER_DAYS, MAX_TERMINAL_FONT_SIZE, MIN_CODE_FONT_SIZE, MIN_GLASS_OPACITY, MIN_INTERFACE_FONT_SIZE, MIN_PROMPT_FONT_SIZE, - MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + MIN_THREAD_AUTO_SETTLE_AFTER_DAYS, MIN_TERMINAL_FONT_SIZE, } from "@t3tools/contracts/settings"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; @@ -489,11 +490,10 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode ? ["Project Grouping"] : []), - ...(settings.sidebarAutoSettleAfterDays !== - DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays + ...(settings.threadAutoSettleAfterDays !== DEFAULT_UNIFIED_SETTINGS.threadAutoSettleAfterDays ? ["Auto-settle inactive threads"] : []), - ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge + ...(settings.threadAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.threadAutoSettleOnMerge ? ["Auto-settle merged threads"] : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), @@ -549,8 +549,8 @@ export function useSettingsRestore(onRestored?: () => void) { settings.glassOpacity, settings.enableLegacyTokenStreaming, settings.enableProviderUpdateChecks, - settings.sidebarAutoSettleAfterDays, - settings.sidebarAutoSettleOnMerge, + settings.threadAutoSettleAfterDays, + settings.threadAutoSettleOnMerge, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, settings.timestampFormat, @@ -631,8 +631,8 @@ export function useSettingsRestore(onRestored?: () => void) { glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, - sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, - sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, + threadAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.threadAutoSettleAfterDays, + threadAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.threadAutoSettleOnMerge, enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, @@ -1594,7 +1594,7 @@ function FontFamilySettingsRow({ ); } -const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3; +const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS; function AutoSettleDaysInput({ value, @@ -1613,8 +1613,8 @@ function AutoSettleDaysInput({ return ( { @@ -1625,8 +1625,8 @@ function AutoSettleDaysInput({ const parsed = Number(event.target.value); if ( Number.isInteger(parsed) && - parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && - parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS + parsed >= MIN_THREAD_AUTO_SETTLE_AFTER_DAYS && + parsed <= MAX_THREAD_AUTO_SETTLE_AFTER_DAYS ) { onCommit(parsed); } @@ -1835,15 +1835,15 @@ export function GeneralSettingsPanel() { updateSettings({ - sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, + threadAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.threadAutoSettleOnMerge, }) } /> @@ -1851,9 +1851,9 @@ export function GeneralSettingsPanel() { } control={ - updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) + updateSettings({ threadAutoSettleOnMerge: Boolean(checked) }) } aria-label="Auto-settle merged threads" /> @@ -1862,15 +1862,15 @@ export function GeneralSettingsPanel() { updateSettings({ - sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, + threadAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.threadAutoSettleAfterDays, }) } /> @@ -1878,24 +1878,24 @@ export function GeneralSettingsPanel() { } control={ updateSettings({ - sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, + threadAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, }) } aria-label="Auto-settle inactive threads" /> } /> - {settings.sidebarAutoSettleAfterDays !== null ? ( + {settings.threadAutoSettleAfterDays !== null ? ( updateSettings({ sidebarAutoSettleAfterDays: days })} + value={settings.threadAutoSettleAfterDays} + onCommit={(days) => updateSettings({ threadAutoSettleAfterDays: days })} /> } /> diff --git a/apps/web/src/hooks/useNowMinute.ts b/apps/web/src/hooks/useNowMinute.ts index 1b9f77b2189b..10d5a35489c5 100644 --- a/apps/web/src/hooks/useNowMinute.ts +++ b/apps/web/src/hooks/useNowMinute.ts @@ -1,10 +1,7 @@ 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. */ +/** Minute-quantized clock ("YYYY-MM-DDTHH:MM") shared by UI surfaces that + refresh minute-granular relative-time labels. */ function currentMinute(): string { return new Date().toISOString().slice(0, 16); @@ -53,11 +50,8 @@ function subscribe(listener: () => void): () => void { } 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. + // With no timer running (no subscribers yet, such as the first render after + // a full unmount), refresh the cached minute before rendering. if (timerId === null) { nowMinute = currentMinute(); } diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index d7ca2305163f..0fedfc57bd07 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -5,12 +5,7 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { - canSnooze, - effectiveSettled, - effectiveSnoozed, - type ChangeRequestStateLike, -} from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { useCallback } from "react"; @@ -60,11 +55,9 @@ export function useThreadActionMenu(input: { readonly threadRef: ScopedThreadRef | null; /** Fallback for "Copy path" when the thread has no worktree. */ readonly projectCwd: string | null; - /** PR state feeding auto-settle classification, as resolved by the caller. */ - readonly changeRequestState: ChangeRequestStateLike | null; readonly onStartRename: () => void; }) { - const { threadRef, projectCwd, changeRequestState, onStartRename } = input; + const { threadRef, projectCwd, onStartRename } = input; const { settleThread, unsettleThread, @@ -79,8 +72,6 @@ export function useThreadActionMenu(input: { }); const handleNewThread = useNewThreadHandler(); const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); - const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); - const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const timestampFormat = useClientSettings((s) => s.timestampFormat); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ @@ -125,17 +116,7 @@ export function useThreadActionMenu(input: { const items = buildThreadActionMenuItems({ branch: thread.branch ?? null, isPinned: thread.pinnedAt != null, - isSettled: - supports.settlement && - effectiveSettled(thread, { - // Minute-quantized like useNowMinute, so this classification - // can never disagree with the sidebar partition or ChatView's - // parked-thread banner within the same minute. - now: `${now.toISOString().slice(0, 16)}:00.000Z`, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequestState, - }), + isSettled: supports.settlement && thread.settledAt !== null, isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), isRegeneratingTitle, @@ -285,9 +266,6 @@ export function useThreadActionMenu(input: { })(); }, [ - autoSettleAfterDays, - autoSettleOnMerge, - changeRequestState, confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 569b4be96e62..ce7b9d2a451c 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -483,9 +483,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( @@ -525,8 +524,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 88a10f8daf88..4058908b10e3 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -53,6 +53,13 @@ T3 Code works with the platforms your team already uses: - Works on GitHub, GitLab, and Bitbucket. Azure DevOps takes a new title and description; its comments stay read-only here, as they already were +**Let completed reviews leave the inbox** + +- T3 Code settles a thread after its PR or MR merges when auto-settle on merge is enabled +- 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 97f397da3e80..d613bed7943a 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -7,30 +7,10 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { - canSettle, - changeRequestAutoSettles, - 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"; - -describe("changeRequestAutoSettles", () => { - it.each([ - ["open", true, false], - ["merged", true, true], - ["merged", false, false], - ["closed", false, true], - [null, false, false], - ] as const)("state=%s autoSettleOnMerge=%s returns %s", (state, autoSettleOnMerge, expected) => { - expect(changeRequestAutoSettles(state, autoSettleOnMerge)).toBe(expected); - }); -}); function makeShell(input: { readonly settledOverride?: "settled" | "active" | null; @@ -83,240 +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("can keep a merged change request active", () => { - const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" }); - expect( - effectiveSettled(recentlyActive, { - now: NOW, - autoSettleAfterDays: null, - autoSettleOnMerge: false, - changeRequestState: "merged", - }), - ).toBe(false); - - expect( - effectiveSettled(recentlyActive, { - now: NOW, - autoSettleAfterDays: null, - autoSettleOnMerge: false, - changeRequestState: "closed", - }), - ).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. @@ -389,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 }), @@ -412,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 e2e93f288889..74cf4833870f 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,40 +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"; - -/** Returns whether the change request state settles the thread immediately. */ -export function changeRequestAutoSettles( - state: ChangeRequestStateLike | null | undefined, - autoSettleOnMerge = true, -): boolean { - return state === "closed" || (state === "merged" && autoSettleOnMerge); -} - -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 @@ -78,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< @@ -94,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; } @@ -122,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; @@ -223,73 +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 can auto-settle on a - * merged PR, always settles on a closed PR, or settles on inactivity past - * the window. 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 autoSettleOnMerge?: boolean; - 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 (changeRequestAutoSettles(options.changeRequestState, options.autoSettleOnMerge !== false)) { - 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. A configured merge, a close, 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 570157292b54..513ba6e27e6d 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -68,11 +68,9 @@ describe("ClientSettings environment identification", () => { }); describe("ClientSettings sidebar", () => { - it("defaults to the current sidebar with automatic merge and inactivity settling", () => { + it("defaults to the current sidebar", () => { const settings = decodeClientSettings({}); expect(settings.legacySidebarEnabled).toBe(false); - expect(settings.sidebarAutoSettleAfterDays).toBe(3); - expect(settings.sidebarAutoSettleOnMerge).toBe(true); }); it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => { @@ -92,24 +90,47 @@ describe("ClientSettings sidebar", () => { ); }); - it("allows auto-settle by inactivity to be disabled", () => { + it("drops the former client-only auto-settle settings", () => { + const decoded = decodeClientSettings({ + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + }); + const patch = decodeClientSettingsPatch({ + sidebarAutoSettleAfterDays: 14, + sidebarAutoSettleOnMerge: false, + }); + + expect(decoded).not.toHaveProperty("sidebarAutoSettleAfterDays"); + expect(decoded).not.toHaveProperty("sidebarAutoSettleOnMerge"); + expect(patch).not.toHaveProperty("sidebarAutoSettleAfterDays"); + expect(patch).not.toHaveProperty("sidebarAutoSettleOnMerge"); + }); +}); + +describe("ServerSettings thread auto-settle", () => { + it("defaults inactivity settlement to three days and can disable it", () => { + expect(decodeServerSettings({}).threadAutoSettleAfterDays).toBe(3); + expect( + decodeServerSettings({ threadAutoSettleAfterDays: null }).threadAutoSettleAfterDays, + ).toBe(null); expect( - decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, + decodeServerSettingsPatch({ threadAutoSettleAfterDays: null }).threadAutoSettleAfterDays, ).toBeNull(); }); - it("allows auto-settle on merge to be disabled", () => { - expect(decodeClientSettings({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge).toBe( + it("defaults merge settlement on and can disable it", () => { + expect(decodeServerSettings({}).threadAutoSettleOnMerge).toBe(true); + expect(decodeServerSettings({ threadAutoSettleOnMerge: false }).threadAutoSettleOnMerge).toBe( false, ); - expect( - decodeClientSettingsPatch({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge, - ).toBe(false); + expect(decodeServerSettingsPatch({ threadAutoSettleOnMerge: false })).toEqual({ + threadAutoSettleOnMerge: false, + }); }); 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 ee1970639adf..9cc320a3005b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -43,16 +43,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( @@ -177,10 +167,6 @@ export const ClientSettingsSchema = Schema.Struct({ // old keys, so everyone, including prior beta opt-outs, resets to the new // default sidebar. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), - ), - sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -212,6 +198,20 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS // import cycle; re-exported here for compatibility with deep imports. export { ThreadEnvMode } from "./environment.ts"; +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, + maximum: MAX_THREAD_AUTO_SETTLE_AFTER_DAYS, + }), +); +export type ThreadAutoSettleAfterDays = typeof ThreadAutoSettleAfterDays.Type; +export const DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS: ThreadAutoSettleAfterDays = 3; + const makeBinaryPathSetting = (fallback: string) => TrimmedString.pipe( Schema.decodeTo( @@ -546,6 +546,10 @@ export const ServerSettings = Schema.Struct({ 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)), + ), + threadAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), backgroundActivity: BackgroundActivitySettings, // Legacy flat fields retained for old settings files and old clients. New // consumers should resolve `backgroundActivity` instead. @@ -709,6 +713,8 @@ export const ServerSettingsPatch = Schema.Struct({ // Server settings enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), + threadAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(ThreadAutoSettleAfterDays)), + threadAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( Schema.Struct({ schemaVersion: Schema.optionalKey(Schema.Literal(1)), @@ -793,8 +799,6 @@ export const ClientSettingsPatch = Schema.Struct({ ), planModeEnabled: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), - sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), - sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),