From 14fbe7ecfce15036598fa8ba75d502069e7e2aeb Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 03:24:24 -0700 Subject: [PATCH 1/2] fix(server): settle threads server-side (#8600) --- .../settings/DesktopClientSettings.test.ts | 2 - apps/mobile/src/features/home/HomeScreen.tsx | 63 +- .../src/features/home/useThreadListActions.ts | 12 +- .../features/settings/SettingsRouteScreen.tsx | 53 +- .../threads/ThreadNavigationSidebar.tsx | 51 +- .../features/threads/thread-list-v2-items.tsx | 26 +- .../src/features/threads/threadListV2.test.ts | 161 +---- .../src/features/threads/threadListV2.ts | 81 +-- .../src/persistence/mobile-preferences.ts | 5 - .../OrchestrationEngineHarness.integration.ts | 7 + .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/git/GitManager.test.ts | 494 +++++++++++++- apps/server/src/git/GitManager.ts | 197 +++++- apps/server/src/orchestration/Errors.ts | 21 +- .../Layers/OrchestrationEngine.test.ts | 215 +++++- .../Layers/OrchestrationEngine.ts | 32 +- .../Layers/OrchestrationReactor.test.ts | 13 +- .../Layers/OrchestrationReactor.ts | 3 + .../Layers/ProviderCommandReactor.test.ts | 45 ++ .../Layers/ProviderCommandReactor.ts | 24 +- .../ThreadSettlementPolicy.test.ts | 159 +++++ .../orchestration/ThreadSettlementPolicy.ts | 108 +++ .../ThreadSettlementReactor.test.ts | 643 ++++++++++++++++++ .../orchestration/ThreadSettlementReactor.ts | 185 +++++ .../src/orchestration/decider.settled.test.ts | 57 +- apps/server/src/orchestration/decider.ts | 125 ++-- .../Layers/OrchestrationEventStore.ts | 23 +- .../Services/OrchestrationEventStore.ts | 5 +- apps/server/src/server.test.ts | 57 +- apps/server/src/server.ts | 21 +- apps/server/src/serverSettings.test.ts | 28 + apps/server/src/ws.ts | 67 +- apps/web/src/components/ChatView.tsx | 60 +- apps/web/src/components/Sidebar.logic.ts | 7 +- apps/web/src/components/Sidebar.tsx | 55 +- .../components/ThreadStatusIndicators.test.ts | 36 +- apps/web/src/components/chat/ChatHeader.tsx | 5 - .../components/settings/SettingsPanels.tsx | 133 ++-- .../settings/settingsSearch.test.ts | 23 +- .../src/components/settings/settingsSearch.ts | 8 +- .../useAvailableSettingsSearchItems.ts | 14 +- apps/web/src/hooks/useNowMinute.ts | 7 +- apps/web/src/hooks/useSettings.test.ts | 18 + apps/web/src/hooks/useSettings.ts | 4 +- apps/web/src/hooks/useThreadActionMenu.ts | 28 +- apps/web/src/hooks/useThreadActions.ts | 27 +- docs/internals/overview.md | 18 +- docs/user/thread-sidebar.md | 11 + .../src/state/threadSettled.test.ts | 587 ---------------- .../client-runtime/src/state/threadSettled.ts | 193 +----- .../src/state/threadSnoozed.test.ts | 60 ++ packages/contracts/src/environment.ts | 2 + packages/contracts/src/orchestration.ts | 8 + packages/contracts/src/settings.test.ts | 39 +- packages/contracts/src/settings.ts | 12 +- 55 files changed, 2675 insertions(+), 1664 deletions(-) create mode 100644 apps/server/src/orchestration/ThreadSettlementPolicy.test.ts create mode 100644 apps/server/src/orchestration/ThreadSettlementPolicy.ts create mode 100644 apps/server/src/orchestration/ThreadSettlementReactor.test.ts create mode 100644 apps/server/src/orchestration/ThreadSettlementReactor.ts delete mode 100644 packages/client-runtime/src/state/threadSettled.test.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index a759e9590..c2c0786e8 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -40,8 +40,6 @@ const clientSettings: ClientSettings = { showSkillsInSlashMenu: false, providerModelPreferences: {}, showProviderUsageInContextPopover: true, - 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 57cbb9125..8052fb193 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -17,6 +17,7 @@ import type { SidebarProjectGroupingMode, SidebarThreadSortOrder, } from "@t3tools/contracts"; +import { useFocusEffect } from "@react-navigation/native"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -52,7 +53,6 @@ import { buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, - type ThreadListV2ChangeRequestState, type ThreadListV2ListItem, } from "../threads/threadListV2"; import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; @@ -208,9 +208,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,33 +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 [changeRequestByKey, setChangeRequestByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => { - setChangeRequestByKey((current) => { - const existing = current.get(threadKey) ?? null; - if ( - (existing?.state ?? null) === (changeRequest?.state ?? null) && - (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) && - (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null) - ) { - return current; - } - const next = new Map(current); - if (changeRequest === null) { - next.delete(threadKey); - } else { - next.set(threadKey, changeRequest); - } - return next; - }); - }, - [], - ); const handleSettleThread = useCallback( (thread: EnvironmentThreadShell) => { void props.onSettleThread(thread); @@ -579,23 +549,21 @@ export function HomeScreen(props: HomeScreenProps) { toggleSettledShelf, toggleSnoozedShelf, } = useThreadListV2ShelfPreferences(); - // 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 queued-start and snooze helpers need a clock while the list stays open. 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 // thread reappears immediately instead of on the next minute tick. 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. - setNowMinute(new Date().toISOString().slice(0, 16)); - const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); - return () => clearInterval(id); - }, [threadListV2Enabled]); + useFocusEffect( + useCallback(() => { + if (!threadListV2Enabled) return; + // Refresh immediately on enable or focus because the previous value can be hours old. + setNowMinute(new Date().toISOString().slice(0, 16)); + const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); + return () => clearInterval(id); + }, [threadListV2Enabled]), + ); // Threads on servers without the settlement capability never classify as // settled (the user could neither un-settle nor pin them). const serverConfigs = useAtomValue(environmentServerConfigsAtom); @@ -677,20 +645,15 @@ export function HomeScreen(props: HomeScreenProps) { projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, searchQuery: props.searchQuery, matchedThreadKeys, - changeRequestByKey, - autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, - now: `${nowMinute}:00.000Z`, - snoozeNow: new Date().toISOString(), + now: new Date().toISOString(), snoozedShelfExpanded, settledShelfExpanded, selectedThreadKey: null, }); }, [ - changeRequestByKey, - autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -862,7 +825,6 @@ export function HomeScreen(props: HomeScreenProps) { onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} onMovePinnedThread={handleMovePinnedThread} - onChangeRequestState={handleChangeRequestState} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null } @@ -872,7 +834,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 5c6694404..dae6c46a8 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,5 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; @@ -118,16 +118,6 @@ 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. - if (action === "settle" && !canSettle(thread, { now: new Date().toISOString() })) { - Alert.alert( - actionFailureTitle(action), - "This thread still needs attention. Resolve or interrupt it first, then try again.", - ); - return false; - } // Archive keeps its original, narrower guard: never interrupt a // thread mid-turn. if ( diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 46062954b..c448ebd6f 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -34,6 +34,9 @@ import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import type { EnvironmentId } from "@t3tools/contracts"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { type AppUpdateCheckState, @@ -527,26 +530,54 @@ function ConfiguredSettingsRouteScreen() { } function GeneralSettingsSection() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; + const { savedConnectionsById } = useSavedRemoteConnections(); + const connections = Object.values(savedConnectionsById).sort((left, right) => + left.environmentLabel.localeCompare(right.environmentLabel), + ); return ( - savePreferences({ autoSettleOnMerge: value })} - /> + {connections.map((connection) => ( + + ))} ); } +function EnvironmentAutoSettleSwitch(props: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; +}) { + const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId)); + const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "auto-settle settings update", + reportFailure: true, + }); + if (config?.environment.capabilities.threadAutoSettlement !== true || settings === null) { + return null; + } + return ( + { + void updateSettings({ + environmentId: props.environmentId, + input: { patch: { sidebarAutoSettleOnMerge: value } }, + }); + }} + /> + ); +} + /** * Device-local legacy toggles. Mobile has no client-settings sync, so this is * the counterpart of web's Settings → General → Legacy features backed by diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 512b9b78a..4a4d36c7a 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -9,7 +9,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 } from "react"; @@ -28,7 +27,6 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; 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 { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; @@ -82,7 +80,6 @@ import { buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, - type ThreadListV2ChangeRequestState, type ThreadListV2ListItem, } from "./threadListV2"; @@ -164,10 +161,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( @@ -365,33 +358,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 [changeRequestByKey, setChangeRequestByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => { - setChangeRequestByKey((current) => { - const existing = current.get(threadKey) ?? null; - if ( - (existing?.state ?? null) === (changeRequest?.state ?? null) && - (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) && - (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null) - ) { - return current; - } - const next = new Map(current); - if (changeRequest === null) { - next.delete(threadKey); - } else { - next.set(threadKey, changeRequest); - } - 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( @@ -414,9 +380,7 @@ function ThreadNavigationSidebarPane( toggleSettledShelf, toggleSnoozedShelf, } = useThreadListV2ShelfPreferences(); - // 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 queued-start and snooze helpers need a clock while the pane stays open. 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 @@ -424,9 +388,7 @@ 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 because the mount-time value can be hours old. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); @@ -509,20 +471,15 @@ function ThreadNavigationSidebarPane( projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, matchedThreadKeys, - changeRequestByKey, - autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, - now: `${nowMinute}:00.000Z`, - snoozeNow: new Date().toISOString(), + now: new Date().toISOString(), snoozedShelfExpanded, settledShelfExpanded, selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ - changeRequestByKey, - autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -931,7 +888,6 @@ function ThreadNavigationSidebarPane( onPinThread={pinThread} onUnpinThread={unpinThread} onMovePinnedThread={movePinnedThread} - onChangeRequestState={handleChangeRequestState} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -1058,7 +1014,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 633a4b141..c3cd8da18 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -23,12 +23,10 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { - resolveThreadListV2ChangeRequestState, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, - type ThreadListV2ChangeRequestState, type ThreadListV2Status, } from "./threadListV2"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; @@ -375,12 +373,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 + last activity) for the partition's - merge and close rules. Mirrors web's onChangeRequestState. */ - readonly onChangeRequestState?: ( - threadKey: string, - changeRequest: ThreadListV2ChangeRequestState | null, - ) => void; readonly projectCwd?: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; @@ -403,24 +395,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 prUpdatedAt = pr?.updatedAt ?? null; - const threadKey = `${thread.environmentId}:${thread.id}`; - useEffect(() => { - const changeRequest = resolveThreadListV2ChangeRequestState({ - linkedPullRequest: thread.linkedPullRequest, - state: prState, - updatedAt: prUpdatedAt, - }); - if (changeRequest === undefined) return; - onChangeRequestState?.(threadKey, changeRequest); - }, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]); const screenColor = useUniwindTheme()["--color-screen"]; const drawerColor = useUniwindTheme()["--color-drawer"]; @@ -458,9 +437,8 @@ 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. + // Swipe: the v2 primary action is the lifecycle transition. Un-settling a + // settled row keeps it active until new activity clears the user override. 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 8a22dca60..9657ab6f0 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -16,7 +16,6 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { buildThreadListV2Items, buildThreadListV2ListItems, - resolveThreadListV2ChangeRequestState, resolveThreadListV2Enabled, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, @@ -61,42 +60,6 @@ const linkedPullRequest = { url: "https://github.com/pingdotgg/t3code/pull/42", }; -describe("resolveThreadListV2ChangeRequestState", () => { - it("preserves the previous state while a linked pull request reloads", () => { - expect( - resolveThreadListV2ChangeRequestState({ - linkedPullRequest, - state: null, - updatedAt: null, - }), - ).toBeUndefined(); - }); - - it("clears the previous state after a pull request is unlinked", () => { - expect( - resolveThreadListV2ChangeRequestState({ - linkedPullRequest: null, - state: null, - updatedAt: null, - }), - ).toBeNull(); - }); - - it("reports a loaded linked pull request", () => { - expect( - resolveThreadListV2ChangeRequestState({ - linkedPullRequest, - state: "merged", - updatedAt: "2026-06-02T00:00:00.000Z", - }), - ).toEqual({ - state: "merged", - updatedAt: "2026-06-02T00:00:00.000Z", - linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]', - }); - }); -}); - describe("resolveThreadListV2SnoozeMenuSelection", () => { it("accepts a displayed evening preset while its wake time is still future", () => { const menuOpenedAt = new Date(2026, 4, 8, 16, 59, 30); @@ -379,51 +342,18 @@ describe("sortThreadsForListV2", () => { }); describe("buildThreadListV2Items", () => { - it("ignores the previous pull request state after a different pull request is linked", () => { - const thread = makeThread({ - id: ThreadId.make("linked"), - title: "Linked pull request", - linkedPullRequest, - }); - const layout = buildThreadListV2Items({ - threads: [thread], - environmentId: null, - searchQuery: "", - changeRequestByKey: new Map([ - [ - `${environmentId}:${thread.id}`, - { - state: "merged" as const, - linkedPullRequestKey: '["project-1","pingdotgg/t3code",41]', - }, - ], - ]), - now: NOW, - }); - - expect(layout.settledCount).toBe(0); - expect(layout.items[0]?.variant).toBe("card"); - }); - - it("settles a thread only when the cached pull request identity matches", () => { + it("places a persisted settled thread in the settled shelf", () => { const thread = makeThread({ id: ThreadId.make("linked-merged"), title: "Linked merged pull request", linkedPullRequest, + settledOverride: "settled", + settledAt: NOW, }); const layout = buildThreadListV2Items({ threads: [thread], environmentId: null, searchQuery: "", - changeRequestByKey: new Map([ - [ - `${environmentId}:${thread.id}`, - { - state: "merged" as const, - linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]', - }, - ], - ]), now: NOW, }); @@ -431,23 +361,6 @@ describe("buildThreadListV2Items", () => { expect(layout.items[0]?.variant).toBe("slim"); }); - 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: "", - changeRequestByKey: new Map([ - [`${environmentId}:${merged.id}`, { state: "merged" as const }], - ]), - 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: [ @@ -499,73 +412,21 @@ describe("buildThreadListV2Items", () => { expect(layout.settledCount).toBe(1); }); - it("moves pinned threads to the settled shelf when their pull request merges", () => { - const merged = makeThread({ - id: ThreadId.make("pinned-merged"), - title: "Pinned merged pull request", - pinnedAt: "2026-06-01T12:00:00.000Z", - }); - const layout = buildThreadListV2Items({ - threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged], - environmentId: null, - searchQuery: "", - changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), - now: NOW, - }); - - expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-merged"]); - expect(layout.items.map((item) => item.variant)).toEqual(["card", "slim"]); - expect(layout.items[1]?.thread.pinnedAt).toBe("2026-06-01T12:00:00.000Z"); - expect(layout.settledCount).toBe(1); - }); - - it("moves inactive pinned threads to the settled shelf", () => { - const inactive = makeThread({ - id: ThreadId.make("pinned-inactive"), - title: "Pinned inactive thread", - createdAt: "2026-05-20T00:00:00.000Z", - pinnedAt: "2026-05-21T00:00:00.000Z", - latestTurn: { - turnId: TurnId.make("turn-inactive"), - state: "completed", - requestedAt: "2026-05-21T00:00:00.000Z", - startedAt: "2026-05-21T00:00:01.000Z", - completedAt: "2026-05-21T00:00:02.000Z", - assistantMessageId: null, - }, - }); - const layout = buildThreadListV2Items({ - threads: [inactive], - environmentId: null, - searchQuery: "", - now: NOW, - }); - - expect(layout.items[0]).toMatchObject({ - thread: { id: "pinned-inactive" }, - variant: "slim", - pinned: false, - }); - expect(layout.settledCount).toBe(1); - }); - - it("keeps pinned merged threads pinned when auto-settle on merge is off", () => { - const merged = makeThread({ - id: ThreadId.make("pinned-merged"), - title: "Pinned merged pull request", + it("keeps active pinned threads in the pinned block", () => { + const pinned = makeThread({ + id: ThreadId.make("pinned"), + title: "Pinned thread", pinnedAt: "2026-06-01T12:00:00.000Z", }); const layout = buildThreadListV2Items({ - threads: [merged], + threads: [pinned], environmentId: null, searchQuery: "", - changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), - autoSettleOnMerge: false, now: NOW, }); expect(layout.items[0]).toMatchObject({ - thread: { id: "pinned-merged" }, + thread: { id: "pinned" }, variant: "card", pinned: true, }); @@ -620,9 +481,7 @@ describe("buildThreadListV2Items", () => { ], environmentId: null, searchQuery: "", - // Minute-floored partition clock vs precise snooze clock. - now: "2026-06-02T00:01:00.000Z", - snoozeNow: "2026-06-02T00:01:07.500Z", + now: "2026-06-02T00:01:07.500Z", }); expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index b6c0d8cfd..f99b7ada7 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,22 +1,18 @@ import { - effectiveSettled, effectiveSnoozed, hasQueuedTurnStart, QUEUED_TURN_START_GRACE_MS, resolveSnoozePresets, snoozeWakeLabel, } from "@t3tools/client-runtime/state/thread-settled"; -import type { - ChangeRequestSettleSource, - SnoozePreset, -} from "@t3tools/client-runtime/state/thread-settled"; +import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { activeThreadAnchorTimestampMs, sortPinnedThreadsByOrderKey, } from "@t3tools/client-runtime/state/thread-sort"; -import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -38,35 +34,6 @@ export type ThreadListV2Status = | "ready"; export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; -export interface ThreadListV2ChangeRequestState extends ChangeRequestSettleSource { - readonly linkedPullRequestKey?: string | null; -} - -function linkedPullRequestKey( - linkedPullRequest: ThreadLinkedPullRequest | null | undefined, -): string | null { - if (linkedPullRequest == null) return null; - return JSON.stringify([ - linkedPullRequest.projectId, - linkedPullRequest.repository.toLowerCase(), - linkedPullRequest.number, - ]); -} - -/** Keep the previous linked PR state while its detail query reloads. */ -export function resolveThreadListV2ChangeRequestState(input: { - readonly linkedPullRequest: ThreadLinkedPullRequest | null | undefined; - readonly state: ChangeRequestSettleSource["state"] | null; - readonly updatedAt: string | null; -}): ThreadListV2ChangeRequestState | null | undefined { - if (input.state === null) return input.linkedPullRequest == null ? null : undefined; - return { - state: input.state, - updatedAt: input.updatedAt, - linkedPullRequestKey: linkedPullRequestKey(input.linkedPullRequest), - }; -} - export function resolveThreadListV2SnoozeMenuSelection(input: { readonly event: string; readonly displayedPresets: ReadonlyArray; @@ -374,8 +341,7 @@ 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. + * the settled recency tail, matching the web v2 list. */ export function buildThreadListV2Items(input: { readonly threads: ReadonlyArray; @@ -386,8 +352,6 @@ export function buildThreadListV2Items(input: { }> | null; readonly searchQuery: string; readonly matchedThreadKeys?: ReadonlySet; - /** Per-row PR reported up by visible rows ("env:threadId" keys). */ - readonly changeRequestByKey?: 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). */ @@ -395,17 +359,10 @@ 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. */ - readonly now?: string; - /** Second-precise clock for snooze classification. Callers pass a - minute-quantized `now` for memoization; snooze wake times are - second-precise, so classifying with the floored minute would hold a - woken thread hidden for up to a minute. Defaults to `now`. */ - readonly snoozeNow?: string; + /** Second-precise clock used for time-based classification. */ + readonly now: string; /** Expands the snoozed shelf into rows. Collapsed is the default. */ readonly snoozedShelfExpanded?: boolean; /** Expands the settled shelf into rows. Expanded is the default. */ @@ -414,10 +371,7 @@ export function buildThreadListV2Items(input: { a split-view detail can never lose its navigation row. */ readonly selectedThreadKey?: string | null; }): ThreadListV2Layout { - const now = input.now ?? new Date().toISOString(); - const snoozeNow = input.snoozeNow ?? now; - const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; - const autoSettleOnMerge = input.autoSettleOnMerge ?? true; + const now = input.now; const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) @@ -429,8 +383,7 @@ 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 shells. The server stamps settledOverride for the tail. if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) { continue; @@ -449,16 +402,8 @@ export function buildThreadListV2Items(input: { } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; - const cachedChangeRequest = - input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; - const changeRequest = - cachedChangeRequest !== null && - (cachedChangeRequest.linkedPullRequestKey ?? null) === - linkedPullRequestKey(thread.linkedPullRequest) - ? cachedChangeRequest - : null; // Snooze outranks settlement and pinning until the thread wakes. - if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { + if (supportsSnooze && effectiveSnoozed(thread, { now })) { snoozed.push(thread); if ( thread.snoozedUntil != null && @@ -469,15 +414,7 @@ export function buildThreadListV2Items(input: { } continue; } - if ( - supportsSettlement && - effectiveSettled(thread, { - now, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, - }) - ) { + if (supportsSettlement && thread.settledOverride === "settled") { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 5d0bd8a3c..cf4c29c60 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -31,7 +31,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 @@ -101,7 +100,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; - autoSettleOnMerge?: boolean; legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; threadListV2SettledShelfExpanded?: boolean; @@ -167,9 +165,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 6ade6025b..c43486623 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -64,6 +64,7 @@ import { type OrchestrationEngineShape, } from "../src/orchestration/Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; +import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -376,6 +377,12 @@ export const makeOrchestrationIntegrationHarness = ( drainThrough: () => Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, { + start: () => Effect.void, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 9c3a0d2a9..a65564507 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -154,6 +154,7 @@ export const make = Effect.gen(function* () { fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, pullRequests: true, threadSettlement: true, + threadAutoSettlement: true, threadSnooze: true, environmentThemes: true, threadPinning: true, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index f7155e76c..09d821532 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -620,6 +620,7 @@ function makeManager(input?: { textGeneration?: Partial; serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; + gitConfigReads?: string[]; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); @@ -629,11 +630,30 @@ function makeManager(input?: { const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings); - const vcsDriverLayer = GitVcsDriver.layer.pipe( - Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(serverConfigLayer), - ); + const vcsDriverLayer = input?.gitConfigReads + ? Layer.effect( + GitVcsDriver.GitVcsDriver, + GitVcsDriver.make.pipe( + Effect.map((service) => + GitVcsDriver.GitVcsDriver.of({ + ...service, + readConfigValue: (cwd, key) => + Effect.sync(() => input.gitConfigReads?.push(key)).pipe( + Effect.andThen(service.readConfigValue(cwd, key)), + ), + }), + ), + ), + ).pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(serverConfigLayer), + ) + : GitVcsDriver.layer.pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(serverConfigLayer), + ); const sourceControlRegistryLayer = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, GitHubSourceControlProvider.make.pipe( @@ -955,6 +975,30 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("a warm PR cache does not reread repository identity for status", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-identity-cache"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-identity-cache"]); + + const gitConfigReads: string[] = []; + const { manager } = yield* makeManager({ gitConfigReads }); + + yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); + gitConfigReads.length = 0; + yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); + + const identityReads = gitConfigReads.filter( + (key) => + key === "branch.feature/status-identity-cache.remote" || key === "remote.origin.url", + ); + expect(identityReads).toHaveLength(0); + }), + ); + it.effect("status skips the provider lookup for a branch that was never pushed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -974,6 +1018,446 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("branch PR lookup returns null when the repository has no remotes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const { manager, ghCalls } = yield* makeManager(); + + const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); + + expect(pullRequest).toBeNull(); + expect(ghCalls).toHaveLength(0); + }), + ); + + it.effect("branch PR lookup uses a saved tracked branch without changing checkout", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/saved-branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/saved-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 216, + title: "Saved branch PR", + url: "https://github.com/pingdotgg/t3code/pull/216", + baseRefName: "main", + headRefName: "feature/saved-branch", + state: "OPEN", + updatedAt: "2026-04-03T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/saved-branch", + }); + + expect(pullRequest).toEqual({ + state: "open", + updatedAt: "2026-04-03T15:00:00.000Z", + }); + expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); + }), + ); + + it.effect("branch PR lookup uses the default branch from a non-origin remote", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "upstream", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "upstream", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "develop"]); + yield* runGit(repoDir, ["push", "-u", "upstream", "develop"]); + yield* runGit(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/develop"]); + yield* runGit(repoDir, ["remote", "set-head", "upstream", "develop"]); + + const { manager } = yield* makeManager({ + ghScenario: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 221, + title: "Merged main PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/221", + baseRefName: "develop", + headRefName: "main", + state: "MERGED", + updatedAt: "2026-04-08T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); + + expect(pullRequest).toEqual({ + state: "merged", + updatedAt: "2026-04-08T15:00:00.000Z", + }); + }), + ); + + it.effect("branch PR lookup uses the saved name after the local branch is deleted", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["branch", "feature/deleted-local-branch/child"]); + yield* runGit(repoDir, [ + "branch", + "--set-upstream-to", + "origin/main", + "feature/deleted-local-branch/child", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 217, + title: "Deleted local branch PR", + url: "https://github.com/pingdotgg/t3code/pull/217", + baseRefName: "main", + headRefName: "feature/deleted-local-branch", + state: "MERGED", + updatedAt: "2026-04-04T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/deleted-local-branch", + }); + + expect(pullRequest).toEqual({ + state: "merged", + updatedAt: "2026-04-04T15:00:00.000Z", + }); + expect(ghCalls.some((call) => call.includes("--head feature/deleted-local-branch"))).toBe( + true, + ); + }), + ); + + it.effect("branch PR lookup recovers a deleted fork branch from its remote-tracking ref", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork"); + yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-fork-branch"]); + yield* runGit(repoDir, ["push", "-u", "team/fork", "feature/deleted-fork-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/deleted-fork-branch"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "team/fork", + "git@github.com:contributor/codething-mvp.git", + forkDir, + ); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + "contributor:feature/deleted-fork-branch": JSON.stringify([ + { + number: 218, + title: "Deleted fork branch PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/218", + baseRefName: "main", + headRefName: "feature/deleted-fork-branch", + state: "MERGED", + updatedAt: "2026-04-05T15:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "contributor/codething-mvp" }, + headRepositoryOwner: { login: "contributor" }, + }, + ]), + }, + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/deleted-fork-branch", + }); + + expect(pullRequest).toEqual({ + state: "merged", + updatedAt: "2026-04-05T15:00:00.000Z", + }); + expect( + ghCalls.some((call) => call.includes("--head contributor:feature/deleted-fork-branch")), + ).toBe(true); + }), + ); + + it.effect("branch PR lookup rejects ambiguous deleted-branch remote refs", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["push", "origin", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["push", "fork", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/ambiguous-remote"]); + const { manager, ghCalls } = yield* makeManager(); + + const error = yield* manager + .branchPullRequest({ cwd: repoDir, branch: "feature/ambiguous-remote" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "GitManagerError", + detail: "Multiple remotes track feature/ambiguous-remote. Its pull request is ambiguous.", + }); + expect(ghCalls).toHaveLength(0); + }), + ); + + it.effect("branch PR lookup does not reuse a cached PR after the remote is repointed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originalRemoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/repointed-lookup"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/repointed-lookup"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:old-owner/old-repository.git", + originalRemoteDir, + ); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 219, + title: "Old repository PR", + url: "https://github.com/old-owner/old-repository/pull/219", + baseRefName: "main", + headRefName: "feature/repointed-lookup", + state: "MERGED", + updatedAt: "2026-04-06T15:00:00Z", + }, + ]), + "[]", + ], + }, + }); + + const first = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/repointed-lookup", + }); + expect(first?.state).toBe("merged"); + + const replacementRemoteDir = yield* createBareRemote(); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:new-owner/new-repository.git", + replacementRemoteDir, + ); + + const second = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/repointed-lookup", + }); + + expect(second).toBeNull(); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); + }), + ); + + it.effect("branch PR lookup shares the status cache for the same repository identity", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/shared-pr-cache"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/shared-pr-cache"]); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 220, + title: "Shared cache PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/220", + baseRefName: "main", + headRefName: "feature/shared-pr-cache", + state: "MERGED", + updatedAt: "2026-04-07T15:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/shared-pr-cache", + }); + + expect(status.pr?.state).toBe("merged"); + expect(pullRequest?.state).toBe("merged"); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); + }), + ); + + it.effect("branch PR lookup propagates provider failures", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/lookup-failure"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-failure"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("gh is not available on PATH"), + }), + }, + }); + + const error = yield* manager + .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" }) + .pipe(Effect.flip); + + expect(error._tag).toBe("SourceControlProviderError"); + }), + ); + + it.effect("status finds a merged PR after its remote branch was deleted", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/merged-branch-deleted"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/merged-branch-deleted"]); + + // GitHub commonly deletes a pull request's head branch after merge. Git + // removes the remote-tracking ref, but preserves the local branch's + // remote and merge configuration as evidence that it was published. + yield* runGit(repoDir, ["push", "origin", "--delete", "feature/merged-branch-deleted"]); + const configuredRemote = yield* runGit(repoDir, [ + "config", + "--get", + "branch.feature/merged-branch-deleted.remote", + ]); + const configuredMerge = yield* runGit(repoDir, [ + "config", + "--get", + "branch.feature/merged-branch-deleted.merge", + ]); + const trackingRef = yield* runGit(repoDir, [ + "for-each-ref", + "--format=%(refname)", + "refs/remotes/origin/feature/merged-branch-deleted", + ]); + expect(configuredRemote.stdout.trim()).toBe("origin"); + expect(configuredMerge.stdout.trim()).toBe("refs/heads/feature/merged-branch-deleted"); + expect(trackingRef.stdout.trim()).toBe(""); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 215, + title: "Merged branch was deleted", + url: "https://github.com/pingdotgg/t3code/pull/215", + baseRefName: "main", + headRefName: "feature/merged-branch-deleted", + state: "MERGED", + mergedAt: "2026-04-02T15:00:00Z", + updatedAt: "2026-04-02T15:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.hasUpstream).toBe(false); + expect(status.pr).toEqual({ + number: 215, + title: "Merged branch was deleted", + url: "https://github.com/pingdotgg/t3code/pull/215", + baseRef: "main", + headRef: "feature/merged-branch-deleted", + state: "merged", + updatedAt: "2026-04-02T15:00:00.000Z", + }); + expect(ghCalls.filter((call) => call.startsWith("pr list ")).length).toBeGreaterThan(0); + }), + ); + it.effect("status still looks up PRs for a branch pushed without --set-upstream", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index c9cab7804..004dcff2d 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -90,6 +90,14 @@ export class GitManager extends Context.Service< input: VcsStatusInput, options?: GitVcsDriver.GitRemoteStatusOptions, ) => Effect.Effect; + /** Resolve the PR for a saved branch without changing the current checkout. */ + readonly branchPullRequest: (input: { + readonly cwd: string; + readonly branch: string; + }) => Effect.Effect< + { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null } | null, + GitManagerServiceError + >; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; readonly invalidateStatus: (cwd: string) => Effect.Effect; @@ -181,6 +189,7 @@ interface BranchHeadContext { preferredHeadSelector: string; remoteName: string | null; headRemoteUrlKey: string | null; + targetRemoteUrlKey: string | null; headRepositoryNameWithOwner: string | null; headRepositoryOwnerLogin: string | null; isCrossRepository: boolean; @@ -969,15 +978,16 @@ export const make = Effect.gen(function* () { prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1); }), ); - // Cache keys are NUL-joined [cwd, branch, upstreamRef, defaultBranch, epoch] — none of the - // segments can contain a NUL byte, and refs are never empty, so "" decodes - // back to a null ref. + // Cache keys are NUL-joined. Automatic settlement validates repository URLs + // against the cached value before it uses a pull request decision. const prLookupCacheKey = ( cwd: string, details: { branch: string; upstreamRef: string | null; defaultBranch: string | null; + localBranchExists?: boolean; + remoteName?: string | null; }, ) => [ @@ -985,6 +995,8 @@ export const make = Effect.gen(function* () { details.branch, details.upstreamRef ?? "", details.defaultBranch ?? "", + details.localBranchExists === false ? "0" : "1", + details.remoteName ?? "", String(prLookupEpoch(cwd)), ].join("\u0000"); // Consecutive failures per cache key, so a branch that keeps failing waits @@ -1006,11 +1018,20 @@ export const make = Effect.gen(function* () { }; const prLookupCache = yield* Cache.makeWith( (key: string) => { - const [cwd = "", branch = "", upstreamRef = "", defaultBranch = ""] = key.split("\u0000"); + const [ + cwd = "", + branch = "", + upstreamRef = "", + defaultBranch = "", + branchExists = "1", + remoteName = "", + ] = key.split("\u0000"); const details = { branch, upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, defaultBranch: defaultBranch.length > 0 ? defaultBranch : null, + localBranchExists: branchExists !== "0", + ...(remoteName.length > 0 ? { remoteName } : {}), }; return Effect.gen(function* () { const headContext = yield* resolveBranchHeadContext(cwd, details); @@ -1035,7 +1056,11 @@ export const make = Effect.gen(function* () { } // Only skip when the branch is untracked as well: anything carrying an // upstream keeps the old behaviour. - if (details.upstreamRef === null && (yield* isUnpublishedBranch(cwd, headContext))) { + if ( + details.localBranchExists && + details.upstreamRef === null && + (yield* isUnpublishedBranch(cwd, headContext)) + ) { return { latest: null, headContext, skipped: true }; } const latest = yield* findLatestPrForHeadContext(cwd, headContext); @@ -1271,11 +1296,33 @@ export const make = Effect.gen(function* () { }; }); + const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")( + function* (cwd: string, branch: string, remoteNameOverride?: string) { + const remoteName = + remoteNameOverride ?? (yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)); + const [headRemote, targetRemote] = yield* Effect.all( + [ + resolveRemoteRepositoryContext(cwd, remoteName), + resolveRemoteRepositoryContext(cwd, "origin"), + ], + { concurrency: "unbounded" }, + ); + return { + remoteName, + headRemoteUrlKey: + headRemote.remoteUrlKey ?? (remoteName === null ? targetRemote.remoteUrlKey : null), + targetRemoteUrlKey: targetRemote.remoteUrlKey, + }; + }, + ); + const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( cwd: string, - details: { branch: string; upstreamRef: string | null }, + details: { branch: string; upstreamRef: string | null; remoteName?: string }, ) { - const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`); + const remoteName = + details.remoteName ?? + (yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`)); const headBranchFromUpstream = details.upstreamRef ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName }) : ""; @@ -1339,6 +1386,7 @@ export const make = Effect.gen(function* () { headRemoteUrlKey: remoteRepository.remoteUrlKey ?? (remoteName === null ? originRepository.remoteUrlKey : null), + targetRemoteUrlKey: originRepository.remoteUrlKey, headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, headRepositoryOwnerLogin: remoteRepository.ownerLogin, isCrossRepository, @@ -1909,6 +1957,140 @@ export const make = Effect.gen(function* () { }); return mergeGitStatusParts(local, remote); }); + const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( + "branchPullRequest", + )(function* ({ cwd, branch }) { + const cacheCwd = yield* normalizeStatusCacheKey(cwd); + const remotes = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.remotes", + cwd: cacheCwd, + args: ["remote"], + }); + const remoteNames = remotes.stdout + .split("\n") + .map((remoteName) => remoteName.trim()) + .filter((remoteName) => remoteName.length > 0); + const [firstRemoteName] = remoteNames; + if (firstRemoteName === undefined) return null; + const branchRef = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.branchRef", + cwd: cacheCwd, + args: [ + "for-each-ref", + "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)", + `refs/heads/${branch}`, + ], + }); + const expectedRefName = `refs/heads/${branch}`; + const exactBranch = branchRef.stdout + .split("\n") + .find((line) => line.split("\u0000", 1)[0] === expectedRefName); + const [refName = "", savedUpstream = "", savedRemoteName = "", savedRemoteRef = ""] = + exactBranch?.split("\u0000") ?? []; + const localBranchExists = refName.length > 0; + let upstreamRef: string | null = null; + let remoteName: string | null = null; + if (savedUpstream.length > 0) { + if (savedRemoteName.length === 0 || savedRemoteRef.length === 0) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Saved upstream for ${branch} is incomplete.`, + }); + } + remoteName = savedRemoteName; + const upstreamBranch = savedRemoteRef.replace(/^refs\/heads\//, ""); + upstreamRef = `${remoteName}/${upstreamBranch}`; + } else if (!localBranchExists) { + const trackingRefs = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.remoteTrackingRefs", + cwd: cacheCwd, + args: ["for-each-ref", "--format=%(refname)", "refs/remotes"], + }); + const refNames = new Set( + trackingRefs.stdout + .split("\n") + .map((remoteRef) => remoteRef.trim()) + .filter((remoteRef) => remoteRef.length > 0), + ); + const matchingRemoteNames = remoteNames.filter((candidate) => + refNames.has(`refs/remotes/${candidate}/${branch}`), + ); + if (matchingRemoteNames.length > 1) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Multiple remotes track ${branch}. Its pull request is ambiguous.`, + }); + } + remoteName = matchingRemoteNames[0] ?? null; + if (remoteName !== null) { + upstreamRef = `${remoteName}/${branch}`; + } + } + const defaultRemoteName = remoteNames.includes("origin") ? "origin" : firstRemoteName; + const defaultBranch = yield* gitCore + .resolveDefaultBranchName(cacheCwd, defaultRemoteName) + .pipe(Effect.orElseSucceed(() => null)); + const cacheKey = prLookupCacheKey(cacheCwd, { + branch, + upstreamRef, + defaultBranch, + localBranchExists, + ...(localBranchExists ? {} : { remoteName }), + }); + let cached = yield* Cache.get(prLookupCache, cacheKey); + const currentIdentity = yield* resolvePrLookupRepositoryIdentity( + cacheCwd, + branch, + remoteName ?? undefined, + ); + const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => + !( + (headContext.headRemoteUrlKey !== null && identity.headRemoteUrlKey === null) || + (headContext.targetRemoteUrlKey !== null && identity.targetRemoteUrlKey === null) + ); + const hasSameIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => + headContext.headRemoteUrlKey === identity.headRemoteUrlKey && + headContext.targetRemoteUrlKey === identity.targetRemoteUrlKey; + if (!canVerifyIdentity(cached.headContext, currentIdentity)) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Repository identity for ${branch} could not be verified.`, + }); + } + if (!hasSameIdentity(cached.headContext, currentIdentity)) { + yield* Cache.invalidate(prLookupCache, cacheKey); + cached = yield* Cache.get(prLookupCache, cacheKey); + const refreshedIdentity = yield* resolvePrLookupRepositoryIdentity( + cacheCwd, + branch, + remoteName ?? undefined, + ); + if ( + !canVerifyIdentity(cached.headContext, refreshedIdentity) || + !hasSameIdentity(cached.headContext, refreshedIdentity) + ) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Repository identity for ${branch} changed during pull request lookup.`, + }); + } + } + const { latest } = cached; + if (latest === null) return null; + if ( + (branch === defaultBranch || + (defaultBranch === null && (branch === "main" || branch === "master"))) && + latest.state !== "open" + ) { + return null; + } + const statusPr = toStatusPr(latest); + return { state: statusPr.state, updatedAt: statusPr.updatedAt }; + }); const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", )(function* (cwd) { @@ -2456,6 +2638,7 @@ export const make = Effect.gen(function* () { localStatus, remoteStatus, status, + branchPullRequest, invalidateLocalStatus, invalidateRemoteStatus, invalidateStatus, diff --git a/apps/server/src/orchestration/Errors.ts b/apps/server/src/orchestration/Errors.ts index 7abd56770..dc29dcbfa 100644 --- a/apps/server/src/orchestration/Errors.ts +++ b/apps/server/src/orchestration/Errors.ts @@ -1,3 +1,4 @@ +import { ThreadId } from "@t3tools/contracts"; import * as SchemaIssue from "effect/SchemaIssue"; import * as Schema from "effect/Schema"; @@ -40,6 +41,24 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass< } } +export class OrchestrationThreadSettleBlockedError extends Schema.TaggedErrorClass()( + "OrchestrationThreadSettleBlockedError", + { + threadId: ThreadId, + }, +) { + override get message(): string { + return "This thread still needs attention. Resolve or interrupt it first, then try again."; + } +} + +export const OrchestrationCommandRejection = Schema.Union([ + OrchestrationCommandInvariantError, + OrchestrationThreadSettleBlockedError, +]); +export type OrchestrationCommandRejection = typeof OrchestrationCommandRejection.Type; +export const isOrchestrationCommandRejection = Schema.is(OrchestrationCommandRejection); + export class OrchestrationCommandPreviouslyRejectedError extends Schema.TaggedErrorClass()( "OrchestrationCommandPreviouslyRejectedError", { @@ -96,7 +115,7 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass< export type OrchestrationDispatchError = | ProjectionRepositoryError - | OrchestrationCommandInvariantError + | OrchestrationCommandRejection | OrchestrationCommandIdConflictError | OrchestrationCommandPreviouslyRejectedError | OrchestrationProjectorDecodeError diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 2fdc20419..8fc151a27 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -11,6 +11,7 @@ import { ProviderInstanceId, } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it as effectIt } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; @@ -18,10 +19,12 @@ import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; import { describe, expect, it } from "vite-plus/test"; import { PersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; +import * as OrchestrationCommandReceipts from "../../persistence/Services/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { @@ -47,27 +50,30 @@ const asMessageId = (value: string): MessageId => MessageId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); -async function createOrchestrationSystem() { +function makeOrchestrationLayer() { const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-orchestration-engine-test-", }); - const orchestrationLayer = Layer.mergeAll( + return Layer.mergeAll( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), ), OrchestrationProjectionSnapshotQueryLive, ).pipe( - Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provideMerge(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationEventStoreLive), - Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); - const runtime = ManagedRuntime.make(orchestrationLayer); +} + +async function createOrchestrationSystem() { + const runtime = ManagedRuntime.make(makeOrchestrationLayer()); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); return { @@ -219,6 +225,7 @@ describe("OrchestrationEngine", () => { } satisfies OrchestrationProjectionPipelineShape), ), Layer.provide(Layer.succeed(OrchestrationEventStore, eventStore)), + Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(NodeServices.layer), @@ -369,6 +376,204 @@ describe("OrchestrationEngine", () => { ).toEqual(currentSession); await system.dispose(); }); + effectIt.effect("preserves the blocked-settle error and persists its rejected receipt", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const receipts = yield* OrchestrationCommandReceipts.OrchestrationCommandReceiptRepository; + const projectId = ProjectId.make("project-blocked-settle"); + const threadId = ThreadId.make("thread-blocked-settle"); + const commandId = CommandId.make("cmd-blocked-settle"); + const createdAt = now(); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-blocked-settle-project-create"), + projectId, + title: "Project", + workspaceRoot: "/tmp/project-blocked-settle", + createdAt, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-blocked-settle-thread-create"), + threadId, + projectId, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-blocked-settle-session-set"), + threadId, + createdAt, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + }); + + const sequence = yield* engine.latestSequence; + const error = yield* engine + .dispatch({ type: "thread.settle", commandId, threadId }) + .pipe(Effect.flip); + const message = + "This thread still needs attention. Resolve or interrupt it first, then try again."; + expect(error).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId, + message, + }); + expect(Option.getOrNull(yield* receipts.getByCommandId({ commandId }))).toMatchObject({ + commandId, + aggregateKind: "thread", + aggregateId: threadId, + status: "rejected", + error: message, + resultSequence: sequence, + }); + expect(yield* engine.latestSequence).toBe(sequence); + }).pipe(Effect.provide(makeOrchestrationLayer())), + ); + + effectIt.effect( + "rejects persisted changes and live background work without blocking unrelated threads", + () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(now())); + const engine = yield* OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery; + const backgroundLiveness = yield* ThreadBackgroundLiveness.ThreadBackgroundLivenessService; + const projectId = ProjectId.make("project-auto-settle-guard"); + const guardedThreadId = ThreadId.make("thread-auto-settle-guarded"); + const unrelatedThreadId = ThreadId.make("thread-auto-settle-unrelated"); + const liveThreadId = ThreadId.make("thread-auto-settle-live"); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-auto-settle-guard-project"), + projectId, + title: "Project", + workspaceRoot: "/tmp/project-auto-settle-guard", + createdAt: now(), + }); + for (const threadId of [guardedThreadId, unrelatedThreadId, liveThreadId]) { + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(`cmd-create-${threadId}`), + threadId, + projectId, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now(), + }); + } + + const beforeUpdate = yield* snapshots.getSnapshot(); + const snapshotSequence = beforeUpdate.snapshotSequence; + const originalUpdatedAt = beforeUpdate.threads.find( + (thread) => thread.id === guardedThreadId, + )?.updatedAt; + yield* engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-auto-settle-guard-meta"), + threadId: guardedThreadId, + branch: "new-branch", + }); + const afterUpdate = yield* snapshots.getSnapshot(); + expect(afterUpdate.threads.find((thread) => thread.id === guardedThreadId)?.updatedAt).toBe( + originalUpdatedAt, + ); + + const staleError = yield* engine + .dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-stale-snapshot"), + threadId: guardedThreadId, + snapshotSequence, + }) + .pipe(Effect.flip); + expect(staleError._tag).toBe("OrchestrationCommandInvariantError"); + + const livenessSnapshotSequence = yield* engine.latestSequence; + for (const [taskType, expectedLiveness] of [ + ["subagent", "working"], + ["local_bash", "monitoring"], + ] as const) { + backgroundLiveness.recordTaskLiveness({ + threadId: liveThreadId, + taskId: `task-${expectedLiveness}`, + taskType, + status: undefined, + kind: "started", + }); + expect(backgroundLiveness.getThreadBackgroundLiveness(liveThreadId)).toBe( + expectedLiveness, + ); + expect(yield* engine.latestSequence).toBe(livenessSnapshotSequence); + + const livenessError = yield* engine + .dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make(`cmd-auto-settle-${expectedLiveness}`), + threadId: liveThreadId, + snapshotSequence: livenessSnapshotSequence, + }) + .pipe(Effect.flip); + expect(livenessError._tag).toBe("OrchestrationCommandInvariantError"); + expect(yield* engine.latestSequence).toBe(livenessSnapshotSequence); + backgroundLiveness.clearThreadLiveness(liveThreadId); + } + + yield* engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-after-liveness-cleared"), + threadId: liveThreadId, + snapshotSequence: livenessSnapshotSequence, + }); + + const freshSnapshotSequence = yield* engine.latestSequence; + yield* engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-auto-settle-unrelated-meta"), + threadId: unrelatedThreadId, + title: "Unrelated update", + }); + yield* engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-after-unrelated-update"), + threadId: guardedThreadId, + snapshotSequence: freshSnapshotSequence, + }); + + const settled = yield* snapshots.getSnapshot(); + expect( + settled.threads.find((thread) => thread.id === guardedThreadId)?.settledOverride, + ).toBe("settled"); + expect(settled.threads.find((thread) => thread.id === liveThreadId)?.settledOverride).toBe( + "settled", + ); + }).pipe(Effect.provide(makeOrchestrationLayer())), + ); it("persists deterministic read models for repeated snapshot reads", async () => { const createdAt = now(); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 0d65d6742..c82eb670f 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -35,6 +35,7 @@ import { toPersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; import { + isOrchestrationCommandRejection, OrchestrationCommandIdConflictError, OrchestrationCommandInvariantError, OrchestrationCommandPreviouslyRejectedError, @@ -47,6 +48,7 @@ import { RollbackAdmission } from "../../rollback/RollbackAdmission.ts"; import { RollbackSagaRepository } from "../../persistence/Services/RollbackSagas.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape, @@ -55,7 +57,6 @@ const isOrchestrationCommandPreviouslyRejectedError = Schema.is( OrchestrationCommandPreviouslyRejectedError, ); const isOrchestrationCommandIdConflictError = Schema.is(OrchestrationCommandIdConflictError); -const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvariantError); function canonicalWorkspacePath(cwd: string): string { try { @@ -98,6 +99,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository; const projectionPipeline = yield* OrchestrationProjectionPipeline; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; const crypto = yield* Crypto.Crypto; const rollbackAdmission = yield* Effect.serviceOption(RollbackAdmission); const rollbackRepository = yield* Effect.serviceOption(RollbackSagaRepository); @@ -291,13 +293,37 @@ const makeOrchestrationEngine = Effect.gen(function* () { yield* assertRollbackFenceAllows(envelope.command); + if ( + envelope.command.type === "thread.auto-settle" && + (yield* eventStore.hasEventAfter({ + aggregateKind: "thread", + aggregateId: envelope.command.threadId, + sequenceExclusive: envelope.command.snapshotSequence, + })) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: `thread ${envelope.command.threadId} changed before automatic settlement`, + }); + } + + if ( + envelope.command.type === "thread.auto-settle" && + threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== null + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: `thread ${envelope.command.threadId} has live background work`, + }); + } + const eventBase = yield* decideOrchestrationCommand({ command: envelope.command, readModel: commandReadModel, }).pipe( Effect.provideService(Crypto.Crypto, crypto), Effect.mapError((cause) => - isOrchestrationCommandInvariantError(cause) + isOrchestrationCommandRejection(cause) ? cause : new OrchestrationCommandInvariantError({ commandType: envelope.command.type, @@ -511,7 +537,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { ), ); - if (isOrchestrationCommandInvariantError(error)) { + if (isOrchestrationCommandRejection(error)) { yield* commandReceiptRepository .upsert({ commandId: envelope.command.commandId, diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index b05ce3b1e..1340480bc 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 * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; @@ -23,7 +24,7 @@ describe("OrchestrationReactor", () => { runtime = null; }); - it("starts provider ingestion, provider command, checkpoint, and thread deletion reactors", async () => { + it("starts every orchestration reactor", async () => { const started: string[] = []; runtime = ManagedRuntime.make( @@ -64,6 +65,15 @@ describe("OrchestrationReactor", () => { drainThrough: () => Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ThreadSettlementReactor.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 fb7543e31..649e80380 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 * as 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.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 7b8046f4d..28b70b4c3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -5033,4 +5033,49 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); expect(thread?.session?.activeTurnId).toBeNull(); }); + + effectIt.effect("stops a ready provider session after automatic settlement", () => + Effect.gen(function* () { + const sessionStopped = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + stopSessionEffect: () => Deferred.succeed(sessionStopped, undefined).pipe(Effect.asVoid), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-for-auto-settle"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex_work"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + const beforeSettlement = yield* Effect.promise(() => harness.readModel()); + + yield* harness.engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-with-session"), + threadId: ThreadId.make("thread-1"), + snapshotSequence: beforeSettlement.snapshotSequence, + }); + + yield* Deferred.await(sessionStopped); + yield* Effect.promise(() => harness.drain()); + const readModel = yield* Effect.promise(() => harness.readModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.settledOverride).toBe("settled"); + expect(thread?.session?.status).toBe("stopped"); + expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 04b1df89c..125f291fa 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -77,7 +77,8 @@ type ProviderIntentEvent = Extract< | "thread.turn-interrupt-requested" | "thread.approval-response-requested" | "thread.user-input-response-requested" - | "thread.session-stop-requested"; + | "thread.session-stop-requested" + | "thread.settled"; } >; @@ -2139,6 +2140,24 @@ const make = Effect.gen(function* () { case "thread.session-stop-requested": yield* processSessionStopRequested(event); return; + case "thread.settled": { + const thread = yield* projectionSnapshotQuery.getThreadShellById(event.payload.threadId); + if ( + Option.isNone(thread) || + thread.value.session == null || + thread.value.session.status === "stopped" + ) { + return; + } + yield* orchestrationEngine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make(`session-stop-for-settle:${event.commandId ?? event.eventId}`), + threadId: event.payload.threadId, + createdAt: event.occurredAt, + onlyIfSettled: true, + }); + return; + } } }); @@ -2330,7 +2349,8 @@ const make = Effect.gen(function* () { event.type === "thread.turn-interrupt-requested" || event.type === "thread.approval-response-requested" || event.type === "thread.user-input-response-requested" || - event.type === "thread.session-stop-requested" + event.type === "thread.session-stop-requested" || + event.type === "thread.settled" ) { return yield* worker.enqueue(event); } diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts new file mode 100644 index 000000000..08d2d2af2 --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + ProviderInstanceId, + ThreadId, + ProjectId, + TurnId, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { shouldAutoSettleThread } from "./ThreadSettlementPolicy.ts"; + +const NOW = "2026-08-28T12:00:00.000Z"; +const makeThread = ( + overrides: Partial = {}, +): OrchestrationThreadShell => ({ + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature", + worktreePath: "/repo", + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, +}); + +const decide = ( + thread: OrchestrationThreadShell, + pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null, + settings: { days?: number | null; merge?: boolean } = {}, +) => + shouldAutoSettleThread({ + thread, + pullRequest, + now: NOW, + autoSettleAfterDays: settings.days === undefined ? 3 : settings.days, + autoSettleOnMerge: settings.merge ?? true, + }); + +describe("shouldAutoSettleThread", () => { + it("settles inactive threads and leaves never-used threads active", () => { + expect(decide(makeThread())).toBe(true); + expect(decide(makeThread({ latestUserMessageAt: null }))).toBe(false); + expect(decide(makeThread(), null, { days: null })).toBe(false); + }); + + it("keeps a thread active at the exact inactivity boundary", () => { + expect(decide(makeThread({ latestUserMessageAt: "2026-08-25T12:00:00.000Z" }))).toBe(false); + }); + + it("keeps open pull requests active", () => { + expect(decide(makeThread(), { state: "open", updatedAt: NOW })).toBe(false); + }); + + it("settles closed requests and honors the merge setting", () => { + expect(decide(makeThread(), { state: "closed", updatedAt: NOW }, { merge: false })).toBe(true); + expect(decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false })).toBe(true); + expect( + decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false, days: null }), + ).toBe(false); + }); + + it("does not settle again after user activity newer than the PR", () => { + expect( + decide( + makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }), + { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" }, + { days: null }, + ), + ).toBe(false); + }); + + it("does not inherit a terminal pull request older than the thread", () => { + expect( + decide( + makeThread({ createdAt: "2026-08-20T00:00:00.000Z", latestUserMessageAt: null }), + { state: "closed", updatedAt: "2026-08-19T00:00:00.000Z" }, + { days: null }, + ), + ).toBe(false); + }); + + it("requires a comparable PR timestamp for immediate settlement", () => { + const recentThread = makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }); + expect(decide(recentThread, { state: "closed", updatedAt: null })).toBe(false); + expect(decide(recentThread, { state: "merged", updatedAt: "unknown" })).toBe(false); + expect(decide(makeThread(), { state: "closed", updatedAt: null })).toBe(true); + }); + + it("uses user request time instead of completion time as the PR anchor", () => { + const thread = makeThread({ + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-08-25T00:00:00.000Z", + startedAt: "2026-08-25T00:01:00.000Z", + completedAt: "2026-08-27T00:00:00.000Z", + assistantMessageId: null, + }, + }); + expect(decide(thread, { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" })).toBe(true); + }); + + it("blocks pins, snooze, pending work, live sessions, and queued starts", () => { + expect(decide(makeThread({ settledOverride: "active" }))).toBe(false); + expect(decide(makeThread({ snoozedUntil: "2026-08-29T00:00:00.000Z" }))).toBe(false); + expect(decide(makeThread({ hasPendingApprovals: true }))).toBe(false); + expect(decide(makeThread({ hasPendingUserInput: true }))).toBe(false); + expect(decide(makeThread({ backgroundLiveness: "working" }))).toBe(false); + expect(decide(makeThread({ backgroundLiveness: "monitoring" }))).toBe(false); + expect( + decide( + makeThread({ + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: TurnId.make("turn-1"), + lastError: null, + updatedAt: NOW, + }, + }), + ), + ).toBe(false); + expect( + decide(makeThread({ latestUserMessageAt: "2026-08-28T11:59:00.000Z", latestTurn: null })), + ).toBe(false); + }); + + it("allows a fresh completion to wake snooze before settlement", () => { + expect( + decide( + makeThread({ + snoozedAt: "2026-08-19T00:00:00.000Z", + snoozedUntil: "2026-08-29T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-woke"), + state: "completed", + requestedAt: "2026-08-18T00:00:00.000Z", + startedAt: "2026-08-18T00:01:00.000Z", + completedAt: "2026-08-20T00:00:00.000Z", + assistantMessageId: null, + }, + }), + ), + ).toBe(true); + }); +}); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts new file mode 100644 index 000000000..5a1030795 --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -0,0 +1,108 @@ +import type { OrchestrationThreadShell } from "@t3tools/contracts"; + +export interface SettlementPullRequest { + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: string | null; +} + +const DAY_MS = 24 * 60 * 60 * 1_000; +export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; + +function latestTimestamp(values: ReadonlyArray): string | null { + let latest: string | null = null; + let latestMs = Number.NEGATIVE_INFINITY; + for (const value of values) { + if (value == null) continue; + const valueMs = Date.parse(value); + if (valueMs > latestMs) { + latest = value; + latestMs = valueMs; + } + } + return latest; +} + +/** A recent user message stays queued until a turn adopts its timestamp. + * Absolute age bounds client clock skew in both directions and stops stale + * pre-adoption data from blocking the thread forever. */ +export function threadHasQueuedTurnStart( + thread: Pick, + now: string, +): boolean { + if (thread.latestUserMessageAt === null || thread.session?.status === "error") return false; + const messageAt = Date.parse(thread.latestUserMessageAt); + const age = Date.parse(now) - messageAt; + if (Number.isNaN(age) || Math.abs(age) > QUEUED_TURN_START_GRACE_MS) return false; + if (thread.latestTurn === null) return true; + return [ + thread.latestTurn.requestedAt, + thread.latestTurn.startedAt, + thread.latestTurn.completedAt, + ].every((value) => value == null || Date.parse(value) < messageAt); +} + +function pullRequestSettles( + thread: Pick, + pullRequest: SettlementPullRequest, + autoSettleOnMerge: boolean, +): boolean { + if (pullRequest.state !== "closed" && (pullRequest.state !== "merged" || !autoSettleOnMerge)) { + return false; + } + if (pullRequest.updatedAt === null) return false; + const userAnchor = latestTimestamp([ + thread.createdAt, + thread.latestUserMessageAt, + thread.latestTurn?.requestedAt, + ]); + if (userAnchor === null) return false; + const pullRequestAt = Date.parse(pullRequest.updatedAt); + const userAnchorAt = Date.parse(userAnchor); + if (Number.isNaN(pullRequestAt) || Number.isNaN(userAnchorAt)) return false; + return pullRequestAt >= userAnchorAt; +} + +export function shouldAutoSettleThread(input: { + readonly thread: OrchestrationThreadShell; + readonly pullRequest: SettlementPullRequest | null; + readonly now: string; + readonly autoSettleAfterDays: number | null; + readonly autoSettleOnMerge: boolean; +}): boolean { + const { thread, pullRequest } = input; + if (!isAutoSettlementCandidate(thread, input.now)) return false; + if (pullRequest !== null) { + if (pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) return true; + if (pullRequest.state === "open") return false; + } + if (input.autoSettleAfterDays === null) return false; + const activityAt = latestTimestamp([ + thread.latestUserMessageAt, + thread.latestTurn?.requestedAt, + thread.latestTurn?.startedAt, + thread.latestTurn?.completedAt, + ]); + if (activityAt === null) return false; + return Date.parse(activityAt) < Date.parse(input.now) - input.autoSettleAfterDays * DAY_MS; +} + +/** Cheap checks that run before any source control lookup. */ +export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now: string): boolean { + if (thread.archivedAt !== null || thread.settledOverride !== null) return false; + if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; + if (thread.session?.status === "starting" || thread.session?.status === "running") return false; + if (thread.backgroundLiveness != null) return false; + if (threadHasQueuedTurnStart(thread, now)) return false; + if (thread.snoozedUntil == null || Date.parse(thread.snoozedUntil) <= Date.parse(now)) + return true; + const wokeOnError = + thread.session?.status === "error" && + (thread.snoozedAt == null || + Date.parse(thread.session.updatedAt) > Date.parse(thread.snoozedAt)); + const wokeOnCompletion = + thread.snoozedAt != null && + thread.latestTurn?.state === "completed" && + thread.latestTurn.completedAt != null && + Date.parse(thread.latestTurn.completedAt) > Date.parse(thread.snoozedAt); + return wokeOnError || wokeOnCompletion; +} diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts new file mode 100644 index 000000000..de5af576a --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -0,0 +1,643 @@ +import { + DEFAULT_SERVER_SETTINGS, + ProjectId, + ProviderInstanceId, + PullRequestOperationError, + ThreadId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThreadShell, + type PullRequestDetail, + type ServerSettings, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { assert, describe, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; + +import { GitManager } from "../git/GitManager.ts"; +import { PullRequestService } from "../pullRequest/PullRequestService.ts"; +import { ServerActivation } from "../serverActivation.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { OrchestrationCommandInvariantError } from "./Errors.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "./Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts"; + +const NOW = "2026-08-28T12:00:00.000Z"; +const PROJECT_ID = ProjectId.make("settlement-project"); +const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project"); + +type AutoSettleCommand = Extract; + +const testCrypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(1), + digest: (_algorithm, data) => Effect.succeed(data), +}); + +function makeProject( + id: ProjectId = PROJECT_ID, + workspaceRoot = "/workspace/project", +): OrchestrationProjectShell { + return { + id, + title: `Project ${id}`, + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: NOW, + }; +} + +function makeThread( + id: string, + overrides: Partial = {}, +): OrchestrationThreadShell { + return { + id: ThreadId.make(id), + projectId: PROJECT_ID, + title: id, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + +function makeSnapshot( + threads: ReadonlyArray, + projects: ReadonlyArray = [makeProject()], +): OrchestrationShellSnapshot { + return { + snapshotSequence: 1, + projects, + threads, + updatedAt: NOW, + }; +} + +function makePullRequestDetail(input: { + readonly projectId: ProjectId; + readonly repository: string; + readonly number: number; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt?: string; +}): PullRequestDetail { + return { + provider: "github", + capabilities: { + diff: true, + comment: true, + actions: [], + mergeMethods: [], + search: true, + review: { inlineComment: true, reply: true, resolve: true, verdicts: [] }, + reviewers: { request: true, listCandidates: true }, + }, + viewerPermissions: { + actions: [], + comment: true, + resolve: true, + verdicts: [], + requestReviewers: true, + }, + projectId: input.projectId, + projectTitle: "Linked project", + workspaceRoot: "/workspace/linked", + repository: input.repository, + number: input.number, + title: "Pull request", + body: "", + url: `https://example.test/${input.repository}/pull/${input.number}`, + author: null, + state: input.state, + isDraft: false, + mergeability: "mergeable", + additions: 0, + deletions: 0, + changedFiles: 0, + headBranch: "feature", + baseBranch: "main", + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: input.updatedAt ?? NOW, + mergedAt: input.state === "merged" ? (input.updatedAt ?? NOW) : null, + closedAt: input.state === "closed" ? (input.updatedAt ?? NOW) : null, + reviewers: [], + labels: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }; +} + +interface HarnessOptions { + readonly snapshot: OrchestrationShellSnapshot; + readonly settings?: ServerSettings; + readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; + readonly pullRequestDetail?: PullRequestService["Service"]["detail"]; + readonly onDispatch?: ( + command: AutoSettleCommand, + ) => Effect.Effect; +} + +const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: HarnessOptions) { + const activation = yield* Deferred.make(); + const snapshots = yield* Ref.make(options.snapshot); + const snapshotReadCount = yield* Ref.make(0); + const snapshotReads = yield* Queue.unbounded(); + const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); + const settingsChanges = yield* PubSub.unbounded(); + const commands = yield* Ref.make>([]); + const branchCalls = yield* Ref.make< + ReadonlyArray<{ readonly cwd: string; readonly branch: string }> + >([]); + const detailCalls = yield* Ref.make< + ReadonlyArray<{ + readonly projectId: ProjectId; + readonly repository: string; + readonly number: number; + }> + >([]); + + const updateSettings = (patch: ServerSettingsPatch) => + Effect.gen(function* () { + const next = applyServerSettingsPatch(yield* Ref.get(settings), patch); + yield* Ref.set(settings, next); + yield* PubSub.publish(settingsChanges, next); + return next; + }); + + const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) => + Ref.update(branchCalls, (calls) => [...calls, input]).pipe( + Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), + ); + + const pullRequestDetail: PullRequestService["Service"]["detail"] = (input) => + Ref.update(detailCalls, (calls) => [...calls, input]).pipe( + Effect.andThen( + options.pullRequestDetail?.(input) ?? + Effect.succeed( + makePullRequestDetail({ + ...input, + state: "open", + }), + ), + ), + ); + + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => { + if (command.type !== "thread.auto-settle") { + return Effect.die(new Error(`Unexpected command: ${command.type}`)); + } + return Ref.update(commands, (recorded) => [...recorded, command]).pipe( + Effect.andThen(options.onDispatch?.(command) ?? Effect.void), + Effect.as({ sequence: 1 }), + ); + }; + + const serverSettings = ServerSettingsService.of({ + start: Effect.void, + ready: Effect.void, + getSettings: Ref.get(settings), + updateSettings, + // Pylon-only member of ServerSettingsService; this reactor never mutates + // provider instances, so the stub only needs to satisfy the shape. + mutateProviderInstances: () => Effect.die(new Error("unexpected provider mutation")), + streamChanges: Stream.fromPubSub(settingsChanges), + subscribeChanges: PubSub.subscribe(settingsChanges).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), + }); + + const dependencies = Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getShellSnapshot: () => + Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe( + Effect.tap((count) => Queue.offer(snapshotReads, count)), + Effect.andThen(Ref.get(snapshots)), + ), + }), + Layer.mock(GitManager)({ branchPullRequest }), + Layer.mock(PullRequestService)({ detail: pullRequestDetail }), + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + Layer.succeed(ServerSettingsService, serverSettings), + Layer.succeed(ServerActivation, Deferred.await(activation)), + Layer.succeed(Crypto.Crypto, testCrypto), + ); + + return { + activation, + snapshots, + snapshotReadCount, + snapshotReads, + commands, + branchCalls, + detailCalls, + updateSettings, + layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)), + }; +}); + +const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( + reactor: ThreadSettlementReactor.ThreadSettlementReactor["Service"], + activation: Deferred.Deferred, + snapshotReads: Queue.Queue, +) { + yield* reactor.start(); + yield* Deferred.succeed(activation, undefined); + yield* Queue.take(snapshotReads); + yield* reactor.drain; +}); + +describe("ThreadSettlementReactor", () => { + it.effect("starts without clients and skips protected threads before pull request lookup", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const linkedPullRequest = { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + } as const; + const skipped = [ + makeThread("pending-approval", { + branch: "skip-approval", + hasPendingApprovals: true, + }), + makeThread("snoozed", { + branch: "skip-snoozed", + snoozedUntil: "2026-08-29T00:00:00.000Z", + }), + ]; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("inactive", { branch: "inactive-feature" }), + makeThread("closed-pr", { linkedPullRequest }), + ...skipped, + ], + [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], + ), + branchPullRequest: () => Effect.succeed(null), + pullRequestDetail: (input) => + Effect.succeed(makePullRequestDetail({ ...input, state: "closed" })), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* reactor.start(); + assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 0); + + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + const commands = yield* Ref.get(fixture.commands); + assert.deepStrictEqual( + commands + .map(({ threadId, snapshotSequence }) => ({ threadId, snapshotSequence })) + .sort((left, right) => left.threadId.localeCompare(right.threadId)), + [ + { + threadId: ThreadId.make("closed-pr"), + snapshotSequence: 1, + }, + { + threadId: ThreadId.make("inactive"), + snapshotSequence: 1, + }, + ], + ); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ + { cwd: "/workspace/project", branch: "inactive-feature" }, + ]); + assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 }, + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("reevaluates inactivity and pull request state once per minute", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const pullRequest = yield* Ref.make<"open" | "merged">("open"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("at-boundary", { + latestUserMessageAt: "2026-08-25T12:00:00.000Z", + }), + makeThread("open-pr", { + branch: "saved-feature", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), + ]), + branchPullRequest: () => + Ref.get(pullRequest).pipe(Effect.map((state) => ({ state, updatedAt: NOW }))), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + + yield* Ref.set(pullRequest, "merged"); + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)) + .map((command) => command.threadId) + .sort((left, right) => left.localeCompare(right)), + [ThreadId.make("at-boundary"), ThreadId.make("open-pr")], + ); + assert.strictEqual((yield* Ref.get(fixture.branchCalls)).length, 2); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make<"merged" | "closed">("merged"); + const firstLookupStarted = yield* Deferred.make(); + const releaseFirstLookup = yield* Deferred.make(); + const laterLookupStarted = yield* Deferred.make(); + const releaseLaterLookup = yield* Deferred.make(); + const lookupCount = yield* Ref.make(0); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("settings-thread", { branch: "saved-feature" })]), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: true, + }, + branchPullRequest: () => + Ref.updateAndGet(lookupCount, (count) => count + 1).pipe( + Effect.tap((count) => + count === 1 + ? Deferred.succeed(firstLookupStarted, undefined) + : count === 3 + ? Deferred.succeed(laterLookupStarted, undefined) + : Effect.void, + ), + Effect.tap((count) => + count === 1 + ? Deferred.await(releaseFirstLookup) + : count === 3 + ? Deferred.await(releaseLaterLookup) + : Effect.void, + ), + Effect.andThen(Ref.get(state)), + Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* reactor.start(); + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* Deferred.await(firstLookupStarted); + + yield* fixture.updateSettings({ sidebarAutoSettleOnMerge: false }); + yield* Deferred.succeed(releaseFirstLookup, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 2); + + yield* Ref.set(state, "closed"); + yield* fixture.updateSettings({ enableAgentBrowserAccess: false }); + yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 1 }); + yield* Deferred.await(laterLookupStarted); + yield* Deferred.succeed(releaseLaterLookup, undefined); + yield* reactor.drain; + + assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 3); + assert.strictEqual(yield* Ref.get(lookupCount), 3); + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("settings-thread")], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("keeps an unknown pull request active and continues with other candidates", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("lookup-failed", { + linkedPullRequest: { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 9, + url: "https://example.test/owner/repository/pull/9", + }, + }), + makeThread("inactive-without-pr"), + ], + [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], + ), + pullRequestDetail: () => + Effect.fail( + new PullRequestOperationError({ + operation: "detail", + detail: "host unavailable", + }), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("inactive-without-pr")], + ); + assert.strictEqual((yield* Ref.get(fixture.detailCalls)).length, 1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("keeps threads active when their pull request project is unavailable", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const linkedPullRequest = { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 10, + url: "https://example.test/owner/repository/pull/10", + } as const; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("missing-own-project", { linkedPullRequest }), + makeThread("missing-branch-project", { branch: "saved-feature" }), + ], + [makeProject(LINKED_PROJECT_ID, "/workspace/linked")], + ), + pullRequestDetail: (input) => + Effect.succeed(makePullRequestDetail({ ...input, state: "open" })), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 }, + ]); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("deduplicates saved-branch and linked pull request lookups within a sweep", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const linkedPullRequest = { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 77, + url: "https://example.test/owner/repository/pull/77", + } as const; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("branch-one", { + branch: "saved-feature", + worktreePath: "/deleted/worktree-one", + }), + makeThread("branch-two", { + branch: "saved-feature", + worktreePath: "/deleted/worktree-two", + }), + makeThread("linked-one", { linkedPullRequest }), + makeThread("linked-two", { linkedPullRequest }), + ], + [ + makeProject(PROJECT_ID, "/workspace/project-root"), + makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"), + ], + ), + branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }), + pullRequestDetail: (input) => + Effect.succeed(makePullRequestDetail({ ...input, state: "merged" })), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ + { cwd: "/workspace/project-root", branch: "saved-feature" }, + ]); + assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 }, + ]); + assert.deepStrictEqual( + new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)), + new Set([ + ThreadId.make("branch-one"), + ThreadId.make("branch-two"), + ThreadId.make("linked-one"), + ThreadId.make("linked-two"), + ]), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("carries the snapshot guard and survives a stale dispatch rejection", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("stale"), makeThread("next-candidate")]), + onDispatch: (command) => + command.threadId === ThreadId.make("stale") + ? Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "thread changed after settlement evaluation", + }), + ) + : Effect.void, + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + const firstSweep = yield* Ref.get(fixture.commands); + assert.strictEqual( + firstSweep.find((command) => command.threadId === ThreadId.make("stale")) + ?.snapshotSequence, + 1, + ); + assert.strictEqual( + firstSweep.some((command) => command.threadId === ThreadId.make("next-candidate")), + true, + ); + + yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.strictEqual((yield* Ref.get(fixture.commands)).length, 4); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); +}); diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts new file mode 100644 index 000000000..fd4486a9c --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -0,0 +1,185 @@ +import { CommandId } from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as GitManager from "../git/GitManager.ts"; +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { forkParked } from "../serverActivation.ts"; +import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; +import { + isAutoSettlementCandidate, + shouldAutoSettleThread, + type SettlementPullRequest, +} from "./ThreadSettlementPolicy.ts"; + +export class ThreadSettlementReactor extends Context.Service< + ThreadSettlementReactor, + { + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; + } +>()("t3/orchestration/ThreadSettlementReactor") {} + +export const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const settingsService = yield* ServerSettings.ServerSettingsService; + const git = yield* GitManager.GitManager; + const pullRequests = yield* PullRequestService.PullRequestService; + const crypto = yield* Crypto.Crypto; + + const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* () { + const snapshot = yield* snapshots.getShellSnapshot(); + const now = DateTime.formatIso(yield* DateTime.now); + const projects = new Map(snapshot.projects.map((project) => [project.id, project])); + const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); + const lookupKey = (thread: (typeof candidates)[number]) => { + if (thread.linkedPullRequest != null) { + return JSON.stringify([ + "linked", + thread.linkedPullRequest.projectId, + thread.linkedPullRequest.repository, + thread.linkedPullRequest.number, + ]); + } + if (thread.branch === null) return JSON.stringify(["none", thread.id]); + const project = projects.get(thread.projectId); + return JSON.stringify( + project === undefined + ? ["missing-project", thread.id] + : ["branch", project.workspaceRoot, thread.branch], + ); + }; + const groups = Map.groupBy(candidates, lookupKey); + + const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* ( + thread: (typeof candidates)[number], + ) { + if (thread.linkedPullRequest != null) { + if (!projects.has(thread.linkedPullRequest.projectId)) { + return yield* Effect.die(new Error("linked pull request project not found")); + } + const detail = yield* pullRequests.detail({ + projectId: thread.linkedPullRequest.projectId, + repository: thread.linkedPullRequest.repository, + number: thread.linkedPullRequest.number, + }); + return { state: detail.state, updatedAt: detail.updatedAt } satisfies SettlementPullRequest; + } + if (thread.branch === null) return null; + const project = projects.get(thread.projectId); + if (project === undefined) { + return yield* Effect.die(new Error("thread project not found")); + } + return yield* git.branchPullRequest({ cwd: project.workspaceRoot, branch: thread.branch }); + }); + + yield* Effect.forEach( + groups.values(), + (group) => + Effect.gen(function* () { + const pullRequest = yield* pullRequestFor(group[0]!); + yield* Effect.forEach( + group, + (thread) => + Effect.gen(function* () { + const settings = yield* settingsService.getSettings; + const decisionNow = DateTime.formatIso(yield* DateTime.now); + if ( + !shouldAutoSettleThread({ + thread, + pullRequest, + now: decisionNow, + autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, + autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, + }) + ) { + return; + } + const uuid = yield* crypto.randomUUIDv4; + yield* engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`), + threadId: thread.id, + snapshotSequence: snapshot.snapshotSequence, + }); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement skipped", { + threadId: thread.id, + cause: Cause.pretty(cause), + }), + ), + ), + { discard: true }, + ); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement skipped", { + threadIds: group.map((thread) => thread.id), + cause: Cause.pretty(cause), + }), + ), + ), + { concurrency: 8, discard: true }, + ); + }); + + const worker = yield* makeDrainableWorker(() => + sweep().pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement sweep failed", { + cause: Cause.pretty(cause), + }), + ), + ), + ); + + const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn( + "ThreadSettlementReactor.start", + )(function* () { + const settingsChanges = yield* settingsService.subscribeChanges; + const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); + let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays; + let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge; + yield* forkParked( + Effect.gen(function* () { + yield* worker.enqueue(undefined); + yield* worker.drain; + }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid), + ); + yield* forkParked( + Stream.runForEach(settingsChanges, (settings) => { + if ( + settings.sidebarAutoSettleAfterDays === lastAfterDays && + settings.sidebarAutoSettleOnMerge === lastOnMerge + ) { + return Effect.void; + } + lastAfterDays = settings.sidebarAutoSettleAfterDays; + lastOnMerge = settings.sidebarAutoSettleOnMerge; + return worker.enqueue(undefined); + }), + ); + }); + + return { start, drain: worker.drain } satisfies ThreadSettlementReactor["Service"]; +}); + +export const layer = Layer.effect(ThreadSettlementReactor, make); diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index 2d0a5d221..51752b9ed 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -19,6 +19,8 @@ import { projectEvent } from "./projector.ts"; const NOW = "2026-01-01T00:00:00.000Z"; const SETTLED_AT = "2025-12-30T00:00:00.000Z"; +const SETTLE_BLOCKED_MESSAGE = + "This thread still needs attention. Resolve or interrupt it first, then try again."; function makeReadModel( settledOverride: OrchestrationThread["settledOverride"], @@ -79,6 +81,22 @@ function makeSession(status: OrchestrationSession["status"]): OrchestrationSessi } it.layer(NodeServices.layer)("settled thread decider", (it) => { + it.effect("rejects an automatic settle when the thread is pinned active", () => + Effect.gen(function* () { + const command = { + type: "thread.auto-settle" as const, + commandId: CommandId.make("cmd-auto-settle"), + threadId: ThreadId.make("thread-1"), + snapshotSequence: 0, + }; + const pinnedActive = yield* decideOrchestrationCommand({ + command, + readModel: makeReadModel("active"), + }).pipe(Effect.flip); + expect(pinnedActive._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + it.effect("settles awake threads without a redundant wake and re-emits idempotently", () => Effect.gen(function* () { const event = yield* decideOrchestrationCommand({ @@ -198,7 +216,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }, readModel: makeReadModel(null, null, makeSession(status)), }).pipe(Effect.flip); - expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); } // Stopped/error sessions are settleable — only live work is protected. const settled = yield* decideOrchestrationCommand({ @@ -240,7 +262,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { requestActivity("approval.requested", "req-1", NOW), ]), }).pipe(Effect.flip); - expect(openError._tag).toBe("OrchestrationCommandInvariantError"); + expect(openError).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); // Same request later resolved: settleable again. const settled = yield* decideOrchestrationCommand({ @@ -268,7 +294,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { requestActivity("user-input.requested", "req-2", NOW), ]), }).pipe(Effect.flip); - expect(inputError._tag).toBe("OrchestrationCommandInvariantError"); + expect(inputError).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); const interactionError = yield* decideOrchestrationCommand({ command: { @@ -280,7 +310,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { requestActivity("interaction.requested", "interaction-1", NOW), ]), }).pipe(Effect.flip); - expect(interactionError._tag).toBe("OrchestrationCommandInvariantError"); + expect(interactionError).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); }), ); @@ -301,8 +335,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { createdAt: NOW, }) as OrchestrationThread["activities"][number]; - // Stale-failure detail clears the request — mirrors the projection's - // pending accounting, which is what the client's canSettle sees. + // Stale-failure details clear the request, matching the projection flags. const settled = yield* decideOrchestrationCommand({ command: { type: "thread.settle", @@ -342,7 +375,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }), ]), }).pipe(Effect.flip); - expect(stillOpen._tag).toBe("OrchestrationCommandInvariantError"); + expect(stillOpen).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); }), ); @@ -370,7 +407,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }, readModel: makeReadModel(null, null, null, [], [userMessage("1969-12-31T23:59:30.000Z")]), }).pipe(Effect.flip); - expect(queuedError._tag).toBe("OrchestrationCommandInvariantError"); + expect(queuedError).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); // Message timestamp far in the FUTURE (client clock ahead of server): // a negative age must not read as queued forever — past the grace diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 455784475..425b5db04 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -3,6 +3,7 @@ import { type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, + type OrchestrationThread, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; @@ -10,7 +11,11 @@ import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import type * as PlatformError from "effect/PlatformError"; -import { OrchestrationCommandInvariantError } from "./Errors.ts"; +import { + OrchestrationCommandInvariantError, + OrchestrationThreadSettleBlockedError, + type OrchestrationCommandRejection, +} from "./Errors.ts"; import { listThreadsByProjectId, requireActiveProjectWorkspaceRootAbsent, @@ -22,14 +27,10 @@ import { requireThreadNotArchived, } from "./commandInvariants.ts"; import { projectEvent } from "./projector.ts"; +import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.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. -const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; - /** * Blocked-on-you work derived from the thread's retained activities: an * approval or user-input request with no later resolution for the same @@ -98,59 +99,28 @@ function hasOpenBlockingRequest(thread: { return openRequestIds.size > 0; } -/** - * A queued turn start — a user message no turn has picked up yet — is work - * in flight even though session is still null (turn.start emits - * message-sent + turn-start-requested; the session arrives later). Detection - * mirrors the client's hasQueuedTurnStart: the newest user message is - * strictly newer than every latestTurn timestamp (adoption stamps the new - * turn's requestedAt with the message time, clearing this), and only within - * the adoption grace window — historical threads whose last user message - * postdates their turn timestamps (older-server data, mid-turn messages) - * must not be blocked forever. A failed session start (status "error") - * clears the block immediately. - * - * The age check is bounded on BOTH sides: message timestamps are - * client-supplied, so a client clock ahead of the server yields a negative - * age. Without the lower bound that negative age satisfies `<= grace` for - * as long as the skew lasts, extending the block far past the intended two - * minutes. - */ -function threadHasQueuedTurnStart( - thread: { - readonly messages: ReadonlyArray<{ readonly role: string; readonly createdAt: string }>; - readonly latestTurn: { - readonly requestedAt: string; - readonly startedAt: string | null; - readonly completedAt: string | null; - } | null; - readonly session: { readonly status: string } | null; - }, - occurredAt: string, +/** Apply the shared shell-level rule to the detailed command read model. */ +function hasQueuedTurnStartForThread( + thread: Pick, + now: string, ): boolean { - const latestUserMessageAtMs = thread.messages.reduce( - (latest, message) => - message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, - Number.NEGATIVE_INFINITY, - ); - const latestTurnAtMs = - thread.latestTurn === null - ? Number.NEGATIVE_INFINITY - : Math.max( - ...[ - thread.latestTurn.requestedAt, - thread.latestTurn.startedAt, - thread.latestTurn.completedAt, - ].map((candidate) => - candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate), - ), - ); - const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs; - return ( - thread.session?.status !== "error" && - Number.isFinite(latestUserMessageAtMs) && - latestUserMessageAtMs > latestTurnAtMs && - Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS + let latestUserMessageAt: string | null = null; + let latestUserMessageAtMs = Number.NEGATIVE_INFINITY; + for (const message of thread.messages) { + if (message.role !== "user") continue; + const messageAtMs = Date.parse(message.createdAt); + latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs); + if (messageAtMs === latestUserMessageAtMs) { + latestUserMessageAt = message.createdAt; + } + } + return threadHasQueuedTurnStart( + { + latestUserMessageAt: Number.isFinite(latestUserMessageAtMs) ? latestUserMessageAt : null, + latestTurn: thread.latestTurn, + session: thread.session, + }, + now, ); } @@ -198,7 +168,7 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ readonly readModel: OrchestrationReadModel; }): Effect.fn.Return< ReadonlyArray, - OrchestrationCommandInvariantError | PlatformError.PlatformError, + OrchestrationCommandRejection | PlatformError.PlatformError, Crypto.Crypto > { let nextReadModel = readModel; @@ -232,7 +202,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" readonly readModel: OrchestrationReadModel; }): Effect.fn.Return< DecideOrchestrationCommandResult, - OrchestrationCommandInvariantError | PlatformError.PlatformError, + OrchestrationCommandRejection | PlatformError.PlatformError, Crypto.Crypto > { switch (command.type) { @@ -465,43 +435,36 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } - case "thread.settle": { + case "thread.settle": + case "thread.auto-settle": { const thread = yield* requireThreadNotArchived({ readModel, 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. - if (thread.session?.status === "starting" || thread.session?.status === "running") { + if (command.type === "thread.auto-settle" && thread.settledOverride !== null) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, - detail: `thread ${command.threadId} has an active session and cannot be settled`, + detail: `thread ${command.threadId} changed before automatic settlement`, }), ); } + // The server owns settle eligibility. A stale command must not settle + // a thread whose session is coming alive or working. + if (thread.session?.status === "starting" || thread.session?.status === "running") { + return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); + } // Pending approval / user-input requests are blocked-on-you work: a // raced or stale client must not park them behind a settled override // that would surface only after the request resolves. if (hasOpenBlockingRequest(thread)) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} has a pending approval, user-input, or interaction request and cannot be settled`, - }), - ); + return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); } const occurredAt = yield* nowIso; // Settling inside the adoption window would hide just-requested work. - if (threadHasQueuedTurnStart(thread, occurredAt)) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} has a queued turn start and cannot be settled`, - }), - ); + if (hasQueuedTurnStartForThread(thread, occurredAt)) { + return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); } // Settling an already-settled thread re-emits with the original // settledAt: the engine rejects zero-event commands, and bulk-settle / @@ -625,7 +588,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // invisible pending work: no session, no pending flags. Snoozing in // that window would hide a just-requested turn exactly the way settle // would. - if (threadHasQueuedTurnStart(thread, occurredAt)) { + if (hasQueuedTurnStartForThread(thread, occurredAt)) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, @@ -1508,7 +1471,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" if ( thread.settledOverride !== "settled" || sessionComingAlive || - threadHasQueuedTurnStart(thread, command.createdAt) + hasQueuedTurnStartForThread(thread, command.createdAt) ) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index edd8620c3..e801c34af 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -64,7 +64,7 @@ const OrchestrationEventPersistedRowSchema = Schema.Struct({ const HasEventAfterRequestSchema = Schema.Struct({ aggregateKind: Schema.String, aggregateId: Schema.String, - type: Schema.String, + type: Schema.optional(Schema.String), sequenceExclusive: NonNegativeInt, }); @@ -271,16 +271,17 @@ const makeEventStore = Effect.gen(function* () { const findEventAfter = SqlSchema.findOneOption({ Request: HasEventAfterRequestSchema, Result: Schema.Struct({ sequence: Schema.Number }), - execute: (request) => - sql` - SELECT sequence - FROM orchestration_events - WHERE aggregate_kind = ${request.aggregateKind} - AND stream_id = ${request.aggregateId} - AND event_type = ${request.type} - AND sequence > ${request.sequenceExclusive} - LIMIT 1 - `, + execute: (request) => sql` + SELECT sequence + FROM orchestration_events + WHERE aggregate_kind = ${request.aggregateKind} + AND stream_id = ${request.aggregateId} + AND ${sql.and([ + sql`sequence > ${request.sequenceExclusive}`, + ...(request.type === undefined ? [] : [sql`event_type = ${request.type}`]), + ])} + LIMIT 1 + `, }); const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) => diff --git a/apps/server/src/persistence/Services/OrchestrationEventStore.ts b/apps/server/src/persistence/Services/OrchestrationEventStore.ts index 488210ab7..b865957c0 100644 --- a/apps/server/src/persistence/Services/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Services/OrchestrationEventStore.ts @@ -54,7 +54,8 @@ export interface OrchestrationEventStoreShape { readonly readAll: () => Stream.Stream; /** - * Check whether an aggregate has an event of the given type after a sequence. + * Check whether an aggregate has an event after a sequence, optionally + * restricted to one event type. * * Used during replay to tell whether a later event supersedes the one being * applied, without streaming the rest of the log. @@ -62,7 +63,7 @@ export interface OrchestrationEventStoreShape { readonly hasEventAfter: (input: { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; readonly aggregateId: string; - readonly type: OrchestrationEvent["type"]; + readonly type?: OrchestrationEvent["type"]; readonly sequenceExclusive: number; }) => Effect.Effect; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3ab040ae9..59e97525b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -122,6 +122,7 @@ import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngi import { OrchestrationCommandInvariantError, OrchestrationListenerCallbackError, + OrchestrationThreadSettleBlockedError, } from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; @@ -8330,7 +8331,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("stops the provider session after settle without closing terminals", () => + it.effect("leaves settle cleanup to the event reactor", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-settle"); const effects: string[] = []; @@ -8388,64 +8389,40 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(dispatchResult.sequence, 1); - assert.deepEqual(effects, ["dispatch:thread.settle", "dispatch:thread.session.stop"]); - const sessionStopCommand = dispatchedCommands[1]; - assert.equal(sessionStopCommand?.type, "thread.session.stop"); - if (sessionStopCommand?.type === "thread.session.stop") { - assert.equal(sessionStopCommand.threadId, threadId); - assert.equal(sessionStopCommand.commandId, "session-stop-for-settle:cmd-thread-settle"); - assert.equal(sessionStopCommand.onlyIfSettled, true); - } + assert.deepEqual(effects, ["dispatch:thread.settle"]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.settle"], + ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("settles without dispatching session stop when the thread has no session", () => + it.effect("forwards the friendly blocked-settlement message over websocket rpc", () => Effect.gen(function* () { - const threadId = ThreadId.make("thread-settle-no-session"); - const effects: string[] = []; - const dispatchedCommands: Array = []; - + const threadId = ThreadId.make("thread-settle-blocked"); yield* buildAppUnderTest({ layers: { - terminalManager: { - close: (input) => - Effect.sync(() => { - effects.push(`terminal.close:${input.threadId}`); - }), - }, orchestrationEngine: { - dispatch: (command) => - Effect.sync(() => { - dispatchedCommands.push(command); - effects.push(`dispatch:${command.type}`); - return { sequence: dispatchedCommands.length }; - }), - }, - projectionSnapshotQuery: { - getThreadShellById: () => - Effect.succeed( - Option.some(makeDefaultOrchestrationThreadShell({ id: threadId, session: null })), - ), + dispatch: () => Effect.fail(new OrchestrationThreadSettleBlockedError({ threadId })), }, }, }); const wsUrl = yield* getWsServerUrl("/ws"); - const dispatchResult = yield* Effect.scoped( + const error = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.settle", - commandId: CommandId.make("cmd-thread-settle-no-session"), + commandId: CommandId.make("cmd-thread-settle-blocked"), threadId, }), - ), + ).pipe(Effect.flip), ); - assert.equal(dispatchResult.sequence, 1); - assert.deepEqual(effects, ["dispatch:thread.settle"]); - assert.deepEqual( - dispatchedCommands.map((command) => command.type), - ["thread.settle"], + assert.equal(error._tag, "OrchestrationDispatchCommandError"); + assert.equal( + error.message, + "This thread still needs attention. Resolve or interrupt it first, then try again.", ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 6a142e492..d4f6db397 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -65,6 +65,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; import * as RollbackSagaRunner from "./rollback/RollbackSagaRunner.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"; @@ -268,6 +269,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(RollbackSagaRuntimeLayerLive), Layer.provideMerge(ThreadDeletionReactorLive), + Layer.provideMerge(ThreadSettlementReactor.layer), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); @@ -308,6 +310,13 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay Layer.provideMerge(VcsDriverRegistryLayerLive), ); +const PullRequestServiceLive = PullRequestService.layer.pipe( + Layer.provide(PullRequestProviderRegistry.layer), + Layer.provide(SourceControlProviderRegistryLayerLive), + Layer.provide(SourceControlRateLimit.layer), + Layer.provide(VcsProcess.layer), +); + const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(ProjectSetupScriptRunner.layer), Layer.provideMerge(GitVcsDriver.layer), @@ -409,7 +418,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(ServerSettingsLayerLive), Layer.provideMerge(CheckpointingLayerLive), - Layer.provideMerge(SourceControlProviderRegistryLayerLive), + Layer.provideMerge( + Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLive), + ), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), @@ -479,14 +490,6 @@ const commandReadinessLayer = HttpRouter.middleware( { global: true }, ); -const PullRequestServiceLive = PullRequestService.layer.pipe( - // One registry entry per supported host; the service only knows the registry. - Layer.provide(PullRequestProviderRegistry.layer), - Layer.provide(SourceControlProviderRegistryLayerLive), - Layer.provide(SourceControlRateLimit.layer), - Layer.provide(VcsProcess.layer), -); - export const makeRoutesLayer = Layer.mergeAll( Layer.mergeAll( HttpApiBuilder.layer(EnvironmentHttpApi).pipe( diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 683e77341..de289816d 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -293,6 +293,34 @@ it.layer(NodeServices.layer)("server settings", (it) => { ).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("persists and broadcasts thread settlement settings", () => + Effect.scoped( + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const changes = yield* serverSettings.subscribeChanges; + + const next = yield* serverSettings.updateSettings({ + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + }); + const change = Option.getOrUndefined(yield* Stream.runHead(changes)); + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + // Inspect raw persisted JSON before schema decoding can apply defaults. + // @effect-diagnostics-next-line preferSchemaOverJson:off + const persisted = JSON.parse(raw) as Record; + + assert.strictEqual(next.sidebarAutoSettleAfterDays, null); + assert.isFalse(next.sidebarAutoSettleOnMerge); + assert.strictEqual(change?.sidebarAutoSettleAfterDays, null); + assert.isFalse(change?.sidebarAutoSettleOnMerge); + assert.strictEqual(persisted.sidebarAutoSettleAfterDays, null); + assert.isFalse(persisted.sidebarAutoSettleOnMerge); + }), + ).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("preserves model when switching providers via textGenerationModelSelection", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 15adccf58..cf907433e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1250,23 +1250,17 @@ const makeWsRpcLayer = ( ORCHESTRATION_WS_METHODS.dispatchCommand, Effect.gen(function* () { const normalizedCommand = yield* normalizeDispatchCommand(command); - // Archive and settle both mean "done with this thread", so a - // live provider session must not keep running background work - // (PR monitors, dev servers, subagent fleets) after either - // lands. The decider rejects settling a starting/running - // session, so for settle this only ever stops an idle one; a - // stopped session-set does not count as activity, so the stop - // cannot un-settle the thread it follows. - const parkingCommand = - normalizedCommand.type === "thread.archive" || - normalizedCommand.type === "thread.settle" - ? normalizedCommand - : undefined; - // Best-effort on purpose: the user's archive/settle must not + // Archive removes the thread from the client, so this transport + // closes its session and terminals after the command lands. + // Settlement cleanup is driven by thread.settled events in the + // provider reactor, including settlements that have no client. + const archiveCommand = + normalizedCommand.type === "thread.archive" ? normalizedCommand : undefined; + // Best-effort on purpose: the user's archive must not // fail because this cleanup read blipped, so a failed read // logs and skips the stop instead of propagating. - const shouldStopSessionAfterCommand = parkingCommand - ? yield* projectionSnapshotQuery.getThreadShellById(parkingCommand.threadId).pipe( + const shouldStopSessionAfterCommand = archiveCommand + ? yield* projectionSnapshotQuery.getThreadShellById(archiveCommand.threadId).pipe( Effect.map( Option.match({ onNone: () => false, @@ -1277,7 +1271,7 @@ const makeWsRpcLayer = ( Effect.catchCause((cause) => Effect.logWarning( "failed to read thread session state before session-stop check", - { threadId: parkingCommand.threadId, cause }, + { threadId: archiveCommand.threadId, cause }, ).pipe(Effect.as(false)), ), ) @@ -1285,50 +1279,39 @@ const makeWsRpcLayer = ( const result = yield* dispatchNormalizedCommand(normalizedCommand).pipe( Effect.tapError(() => cleanupFailedUploadedAttachments(command, normalizedCommand)), ); - if (parkingCommand) { - const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; + if (archiveCommand) { if (shouldStopSessionAfterCommand) { yield* Effect.gen(function* () { const stopCommand = yield* normalizeDispatchCommand({ type: "thread.session.stop", commandId: CommandId.make( - `session-stop-for-${parkingKind}:${parkingCommand.commandId}`, + `session-stop-for-archive:${archiveCommand.commandId}`, ), - threadId: parkingCommand.threadId, + threadId: archiveCommand.threadId, createdAt: yield* nowIso, - // A settled thread can be re-engaged before this stop is - // decided; the decider then drops the stop instead of - // killing the new session. Archive stops stay - // unconditional: turn starts on archived threads are - // rejected, so there is no new session to protect. - ...(parkingKind === "settle" ? { onlyIfSettled: true } : {}), }); yield* dispatchNormalizedCommand(stopCommand); }).pipe( Effect.catchCause((cause) => - Effect.logWarning(`failed to stop provider session during ${parkingKind}`, { - threadId: parkingCommand.threadId, + Effect.logWarning("failed to stop provider session during archive", { + threadId: archiveCommand.threadId, cause, }), ), ); } - // Terminals are user-opened panes, not thread background - // work: archive removes the thread from view so they close - // with it, but a settled thread stays reachable and may be - // un-settled, so its terminals stay up. - if (parkingCommand.type === "thread.archive") { - yield* terminalManager.close({ threadId: parkingCommand.threadId }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to close thread terminals after archive", { - threadId: parkingCommand.threadId, - error: error.message, - }), - ), - ); - } + // Archive removes the thread from view, so its user-opened + // terminal panes close with it. + yield* terminalManager.close({ threadId: archiveCommand.threadId }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to close thread terminals after archive", { + threadId: archiveCommand.threadId, + error: error.message, + }), + ), + ); } return result; }).pipe( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c549dd7e1..c30f70b85 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -44,12 +44,7 @@ import { type RollbackTarget, } from "@t3tools/client-runtime/rollback"; import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; -import { - changeRequestAutoSettles, - effectiveSettled, - effectiveSnoozed, - threadWokeAt, -} from "@t3tools/client-runtime/state/thread-settled"; +import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { codexFeedbackMessage, parseCodexFeedbackCommand, @@ -4799,9 +4794,7 @@ 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. + // The server-projected settled state keeps the banner and sidebar in sync. const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const activeComposerPlan = activePlan && activePlan.turnId === activeLatestTurn?.turnId ? activePlan : null; @@ -4857,9 +4850,6 @@ function ChatViewContent(props: ChatViewProps) { resizeObserver.disconnect(); }; }, [composerOverlayElement]); - - const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); - const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); const linkedPullRequestStatus = useLinkedThreadPullRequest( activeThreadRef?.environmentId ?? null, linkedThreadPullRequest, @@ -4880,18 +4870,6 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadPr, openThreadPullRequest]); const pullRequestSurfaceAvailable = supportsPullRequests && activeThreadPr !== null && threadRepository !== null; - // Primitive slice of the displayed PR for the settle-rule memos below: - // resolveDisplayedThreadPr returns a fresh object every render, so memoize - // on the fields the rules read instead of the object identity. - const activeThreadPrState = activeThreadPr?.state ?? null; - const activeThreadPrUpdatedAt = activeThreadPr?.updatedAt ?? null; - const activeThreadChangeRequest = useMemo( - () => - activeThreadPrState === null - ? null - : { state: activeThreadPrState, updatedAt: activeThreadPrUpdatedAt }, - [activeThreadPrState, activeThreadPrUpdatedAt], - ); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true; @@ -4922,21 +4900,13 @@ function ChatViewContent(props: ChatViewProps) { if (activeThreadRef === null || activeThreadWokeAt === null) return; markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); - // Mirror of the sidebar's Woke pill for the open thread. It uses the same - // visit comparison and change request settle rule. + // Mirror of the sidebar's Woke pill for the open thread. const activeThreadLastVisitedAt = useUiStateStore((store) => activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], ); const activeThreadWokeVisible = useMemo(() => { if (activeThreadWokeAt === null) return false; - if ( - changeRequestAutoSettles(activeThreadChangeRequest, { - autoSettleOnMerge, - thread: activeThreadShell, - }) - ) { - return false; - } + if (activeThreadShell?.settledOverride === "settled") 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 @@ -4956,27 +4926,8 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeLatestTurn?.completedAt, activeThreadLastVisitedAt, - activeThreadChangeRequest, activeThreadShell, activeThreadWokeAt, - autoSettleOnMerge, - ]); - const activeThreadSettled = useMemo(() => { - if (activeThreadShell === null || !supportsSettlement) return false; - return effectiveSettled(activeThreadShell, { - now: `${nowMinute}:00.000Z`, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest: activeThreadChangeRequest, - }); - }, [ - activeThreadChangeRequest, - activeThreadShell, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequestSnapshotByKey, - nowMinute, - supportsSettlement, ]); // ------------------------------------------------------------------ // Cross-account handoff @@ -5088,6 +5039,8 @@ function ChatViewContent(props: ChatViewProps) { [activeThreadContinuedFrom, activeThreadId, allThreadShells, threadHandoffEntries], ); + const activeThreadSettled = + supportsSettlement && activeThreadShell?.settledOverride === "settled"; const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false, }); @@ -8311,7 +8264,6 @@ function ChatViewContent(props: ChatViewProps) { {...(routeKind === "draft" && draftId ? { draftId } : {})} activeThreadTitle={activeThread.title} isServerThread={isServerThread} - changeRequest={activeThreadChangeRequest} activeProjectName={activeProject?.title} activeProjectCwd={activeProject?.workspaceRoot ?? null} activeProjectFaviconPath={activeProject?.faviconPath ?? null} diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index dc8315f95..4e742fbba 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -668,11 +668,8 @@ type SettledTimestampInput = Pick< "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt" >; -/** 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 timestamp a settled row sorts and labels by: settledAt when stamped, + otherwise the latest message or turn stamp. updatedAt is the final net. */ export function resolveSettledTimestamp(thread: SettledTimestampInput): string | null { const settledAt = firstValidTimestamp(thread.settledAt); if (settledAt !== null) return settledAt; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7b614ea73..1d9a349eb 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"; @@ -717,7 +715,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; // Pinned threads show the same pin marker in active, settled, and snoozed @@ -842,10 +839,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && - !changeRequestAutoSettles(pr, { - autoSettleOnMerge: props.autoSettleOnMerge, - thread, - }); + thread.settledOverride !== "settled"; // In-flight work fades as a whole because it does not need the user yet. // Approval and Input are attention states, so they stay prominent even when // the row is not active. Ready rows recede after their completion is read. @@ -1733,8 +1727,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 confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); @@ -1931,8 +1923,6 @@ 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 @@ -2074,7 +2064,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 @@ -2100,29 +2089,10 @@ 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 snapshot = changeRequestSnapshotByKey.get(threadKey); - const changeRequest = - snapshot != null && - (thread.linkedPullRequest == null - ? thread.worktreePath === null || snapshot.branch === thread.branch - : snapshot.linkedPullRequest?.projectId === thread.linkedPullRequest.projectId && - snapshot.linkedPullRequest.repository === thread.linkedPullRequest.repository && - snapshot.linkedPullRequest.number === thread.linkedPullRequest.number) - ? snapshot.pr - : null; // Snooze outranks settlement and pinning until the thread wakes. if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { snoozed.push(thread); - } else if ( - supportsSettlement && - effectiveSettled(thread, { - now, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, - }) - ) { + } else if (supportsSettlement && thread.settledOverride === "settled") { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); @@ -2156,16 +2126,7 @@ export default function Sidebar() { settledThreads: sortSettledThreadsForSidebar(settled), snoozeNow: preciseNow, }; - }, [ - autoSettleAfterDays, - autoSettleOnMerge, - changeRequestSnapshotByKey, - nowMinute, - scopedProjectKeys, - serverConfigs, - snoozeWakeTick, - threads, - ]); + }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); @@ -3104,9 +3065,8 @@ export default function Sidebar() { thread.worktreePath ?? projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null; - // Un-settle works on every settled row: for explicit settles it - // clears the override, for auto-settled rows it pins the thread - // active until real activity clears the pin. Environments without + // Un-settle pins the thread active until real activity clears the pin. + // Environments without // the settlement capability get no lifecycle items at all. const supportsSettlement = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === @@ -3770,9 +3730,7 @@ export default function Sidebar() { key={`${threadKey}:${rowVariant}`} thread={thread} variant={rowVariant} - // Snoozed rows wake; settled rows un-settle (explicit - // settles clear the override, auto-settled rows get - // pinned active); cards settle. + // Snoozed rows wake, settled rows un-settle, and cards settle. variantAction={ section === "snoozed" ? "unsnooze" @@ -3784,7 +3742,6 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSettlement === true } - autoSettleOnMerge={autoSettleOnMerge} snoozeSupported={ serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSnooze === true diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 3710bcea8..2663af161 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,6 +1,4 @@ -import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; -import type { OrchestrationThreadShell } from "@t3tools/contracts"; -import { ProjectId, ProviderInstanceId, ThreadId, type VcsStatusResult } from "@t3tools/contracts"; +import { ProjectId, type VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { AtomRegistry } from "effect/unstable/reactivity"; @@ -495,7 +493,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { ).toEqual(mergedPr); }); - it("keeps effectiveSettled true for a retained merged PR after a main checkout", () => { + it("retains a merged PR after a main checkout", () => { const matchingStatus = status({ refName: featureBranch, pr: mergedPr, @@ -518,36 +516,6 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { retainTerminalOnBranchMismatch: true, }); expect(displayed?.state).toBe("merged"); - - const shell = { - id: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-1"), - title: "Feature thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - latestTurn: null, - session: null, - createdAt: "2026-04-09T00:00:00.000Z", - updatedAt: "2026-04-09T00:00:00.000Z", - archivedAt: null, - settledAt: null, - settledOverride: null, - latestUserMessageAt: "2026-04-09T00:00:00.000Z", - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - } as OrchestrationThreadShell; - - expect( - effectiveSettled(shell, { - now: "2026-04-10T00:00:00.000Z", - autoSettleAfterDays: null, - changeRequest: displayed, - }), - ).toBe(true); }); }); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index dbba32748..6a5f23d31 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 { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled"; import { ChevronDownIcon } from "lucide-react"; import { memo, @@ -53,8 +52,6 @@ interface ChatHeaderProps { activeThreadTitle: string; /** Drafts have no server thread yet, so the title carries no action menu. */ isServerThread: boolean; - /** PR feeding the settled classification, resolved by ChatView. */ - changeRequest: ChangeRequestSettleSource | null; activeProjectName: string | undefined; activeProjectCwd: string | null; activeProjectFaviconPath: string | null; @@ -123,7 +120,6 @@ export const ChatHeader = memo(function ChatHeader({ draftId, activeThreadTitle, isServerThread, - changeRequest, activeProjectName, activeProjectCwd, activeProjectFaviconPath, @@ -201,7 +197,6 @@ export const ChatHeader = memo(function ChatHeader({ const { openMenu, closeMenu } = useThreadActionMenu({ threadRef: isServerThread ? activeThreadRef : null, projectCwd: activeProjectCwd, - changeRequest, 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 22be22f7a..eb90a8064 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -80,7 +80,11 @@ import { } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; -import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server"; +import { + primaryServerConfigAtom, + primaryServerObservabilityAtom, + primaryServerProvidersAtom, +} from "../../state/server"; import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel } from "../../timestampFormat"; @@ -1898,6 +1902,8 @@ export function GeneralSettingsPanel() { ); const observability = useAtomValue(primaryServerObservabilityAtom); const serverProviders = useAtomValue(primaryServerProvidersAtom); + const supportsAutoSettlement = + useAtomValue(primaryServerConfigAtom)?.environment.capabilities.threadAutoSettlement === true; const diagnosticsDescription = formatDiagnosticsDescription({ localTracingEnabled: observability?.localTracingEnabled ?? false, otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, @@ -1985,72 +1991,77 @@ export function GeneralSettingsPanel() { } /> - - updateSettings({ - sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, - }) - } - /> - ) : null - } - control={ - - updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) + {supportsAutoSettlement ? ( + <> + + updateSettings({ + sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, + }) + } + /> + ) : null + } + control={ + + updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) + } + aria-label="Auto-settle merged threads" + /> } - aria-label="Auto-settle merged threads" /> - } - /> - - updateSettings({ - sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, - }) + + updateSettings({ + sidebarAutoSettleAfterDays: + DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, + }) + } + aria-label="Auto-settle inactive threads" + /> } - aria-label="Auto-settle inactive threads" /> - } - /> - {settings.sidebarAutoSettleAfterDays !== null ? ( - updateSettings({ sidebarAutoSettleAfterDays: days })} + {settings.sidebarAutoSettleAfterDays !== null ? ( + updateSettings({ sidebarAutoSettleAfterDays: days })} + /> + } /> - } - /> + ) : null} + ) : null} { it("hides desktop-only settings from browser search", () => { expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); - expect(searchSettings("quit confirmation")).toEqual([]); + expect(searchSettings("hold to quit")).toEqual([]); expect(searchSettings("wsl")).toEqual([]); }); @@ -134,6 +134,7 @@ describe("searchSettings", () => { hasProviderSettingsEnvironment: false, canManageLocalBackend: false, isWslSettingsRowVisible: false, + hasThreadAutoSettlement: false, }); const gatedIds = new Set([ @@ -147,10 +148,30 @@ describe("searchSettings", () => { "t3-connect", "tailscale-https", "wsl-backend", + "auto-settle-inactive-threads", + "auto-settle-merged-threads", + "days-before-auto-settle", ]); expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]); }); + it("shows automatic settlement settings when the server supports them", () => { + const available = filterAvailableSettingsSearchItems({ + hasCloudPublicConfig: false, + hasPrimaryEnvironment: false, + hasProviderSettingsEnvironment: false, + canManageLocalBackend: false, + isWslSettingsRowVisible: false, + hasThreadAutoSettlement: true, + }); + + expect(searchSettings("auto-settle", available).map((item) => item.id)).toEqual([ + "auto-settle-inactive-threads", + "auto-settle-merged-threads", + "days-before-auto-settle", + ]); + }); + it("keeps catalog result ids unique", () => { const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); expect(new Set(ids).size).toBe(ids.length); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index b81f6810e..8c2ab922f 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -30,6 +30,7 @@ export interface SettingsSearchItem { readonly providerSettingsOnly?: boolean; readonly localBackendManagementOnly?: boolean; readonly wslAvailableOnly?: boolean; + readonly requiresThreadAutoSettlement?: boolean; } export interface SettingsSearchAvailability { @@ -38,6 +39,7 @@ export interface SettingsSearchAvailability { readonly hasProviderSettingsEnvironment: boolean; readonly canManageLocalBackend: boolean; readonly isWslSettingsRowVisible: boolean; + readonly hasThreadAutoSettlement: boolean; } /** @@ -148,12 +150,14 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Auto-settle inactive threads", to: "/settings/general", searchTerms: ["sidebar inactivity days no activity automatically"], + requiresThreadAutoSettlement: true, }, { id: "auto-settle-merged-threads", title: "Auto-settle merged threads", to: "/settings/general", searchTerms: ["pull request merge closed automatically sidebar"], + requiresThreadAutoSettlement: true, }, { id: "days-before-auto-settle", @@ -161,6 +165,7 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", targetId: "auto-settle-inactive-threads", searchTerms: ["thread timeout activity sidebar"], + requiresThreadAutoSettlement: true, }, { id: "time-format", @@ -461,7 +466,8 @@ export function filterAvailableSettingsSearchItems( (!item.primaryOnly || availability.hasPrimaryEnvironment) && (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) && (!item.localBackendManagementOnly || availability.canManageLocalBackend) && - (!item.wslAvailableOnly || availability.isWslSettingsRowVisible), + (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) && + (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement), ); } diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts index 2d426d763..a2f5ca627 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useAtomValue } from "@effect/atom-react"; import { AuthAccessWriteScope } from "@t3tools/contracts"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; @@ -7,6 +8,7 @@ import { desktopWslStateAtom } from "~/state/desktopWslState"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { usePrimarySessionState } from "~/environments/primary"; +import { primaryServerConfigAtom } from "~/state/server"; import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; import { filterAvailableSettingsSearchItems } from "./settingsSearch"; @@ -15,6 +17,7 @@ export function useAvailableSettingsSearchItems() { const primaryEnvironmentId = usePrimaryEnvironmentId(); const { environments } = useEnvironments(); const primarySessionState = usePrimarySessionState(); + const primaryServerConfig = useAtomValue(primaryServerConfigAtom); const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); const canManageLocalBackend = isElectron || @@ -38,7 +41,16 @@ export function useAvailableSettingsSearchItems() { state: desktopWsl.data, error: desktopWsl.error, }), + hasThreadAutoSettlement: + primaryServerConfig?.environment.capabilities.threadAutoSettlement === true, }), - [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments, primaryEnvironmentId], + [ + canManageLocalBackend, + desktopWsl.data, + desktopWsl.error, + environments, + primaryEnvironmentId, + primaryServerConfig, + ], ); } diff --git a/apps/web/src/hooks/useNowMinute.ts b/apps/web/src/hooks/useNowMinute.ts index 1b9f77b21..81168e545 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 UI clock ("YYYY-MM-DDTHH:MM"). One module-level timer + feeds every consumer through useSyncExternalStore. */ function currentMinute(): string { return new Date().toISOString().slice(0, 16); diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index b332fe13c..b3009b260 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -76,4 +76,22 @@ describe("mergeEnvironmentSettings", () => { expect(settings.providerInstances).toBe(serverSettings.providerInstances); expect(settings.favorites).toBe(clientSettings.favorites); }); + + it("keeps server settlement settings when legacy client data contains retired keys", () => { + const serverSettings = { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: 14, + sidebarAutoSettleOnMerge: false, + }; + const legacyClientSettings = { + ...DEFAULT_CLIENT_SETTINGS, + sidebarAutoSettleAfterDays: 1, + sidebarAutoSettleOnMerge: true, + }; + + const settings = mergeEnvironmentSettings(serverSettings, legacyClientSettings); + + expect(settings.sidebarAutoSettleAfterDays).toBe(14); + expect(settings.sidebarAutoSettleOnMerge).toBe(false); + }); }); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index d92cd6931..64d7e5de0 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -218,7 +218,9 @@ export function mergeEnvironmentSettings( serverSettings: ServerSettings, clientSettings: ClientSettings, ): UnifiedSettings { - return { ...serverSettings, ...clientSettings }; + // Decode drops retired client keys, but older untyped persistence adapters + // can still return them. Server-owned values must always win. + return { ...clientSettings, ...serverSettings }; } function useMergedSettings( diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 1602e5cf8..a073d8470 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 ChangeRequestSettleSource, -} 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 feeding auto-settle classification, as resolved by the caller. */ - readonly changeRequest: ChangeRequestSettleSource | null; readonly onStartRename: () => void; }) { - const { threadRef, projectCwd, changeRequest, onStartRename } = input; + const { threadRef, projectCwd, onStartRename } = input; const { settleThread, unsettleThread, @@ -80,8 +73,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 confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); @@ -127,17 +118,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, - changeRequest, - }), + isSettled: supports.settlement && thread.settledOverride === "settled", isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), isRegeneratingTitle, @@ -311,9 +292,6 @@ export function useThreadActionMenu(input: { }, [ archiveThread, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, confirmThreadArchive, confirmThreadDelete, confirmAndUnpinThread, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 527081dcf..64915228c 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -5,7 +5,7 @@ import { scopedThreadKey, } from "@t3tools/client-runtime/environment"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { canSettle, canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; @@ -64,18 +64,6 @@ export class ThreadSettlementUnsupportedError extends Schema.TaggedErrorClass()( - "ThreadSettleBlockedError", - { - environmentId: EnvironmentId, - threadId: ThreadId, - }, -) { - override get message(): string { - return "This thread still needs attention. Resolve or interrupt it first, then try again."; - } -} - export class ThreadSnoozeUnsupportedError extends Schema.TaggedErrorClass()( "ThreadSnoozeUnsupportedError", { @@ -506,19 +494,6 @@ 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. - if (resolved && !canSettle(resolved.thread, { now: new Date().toISOString() })) { - return AsyncResult.failure( - Cause.fail( - new ThreadSettleBlockedError({ - environmentId: resolved.threadRef.environmentId, - threadId: resolved.threadRef.threadId, - }), - ), - ); - } const wokeAt = resolved ? threadWokeAt(resolved.thread, { now: new Date().toISOString() }) : null; diff --git a/docs/internals/overview.md b/docs/internals/overview.md index ff88cefcd..bfed10feb 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -88,18 +88,29 @@ A turn is complete when its session leaves `running` status, projected by `settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later does not define turn end. +Thread settlement is server-owned. Per-environment settings control PR and inactivity settlement. +[`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and +once per minute, including when no client is connected. It dispatches the guarded internal +`thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic +settlement excludes live background work and requires a comparable PR timestamp for immediate PR +settlement. The command also rejects any later event for its thread after the reactor's snapshot. +Clients render the persisted settlement state and do not derive settlement from PR or inactivity +state. A committed `thread.settled` event also lets `ProviderCommandReactor` stop an idle provider +session. + ## Drainable workers Follow-up work runs asynchronously in queue-backed workers built on [`DrainableWorker`][worker]: [`ProviderRuntimeIngestion`][ingest] normalizes provider runtime streams into orchestration commands, -[`ProviderCommandReactor`][cmd] dispatches provider calls in response to intent events, and +[`ProviderCommandReactor`][cmd] dispatches provider calls in response to intent events, [`CheckpointReactor`][checkpoint] captures workspace checkpoints and rejects coordinated rollback -requests while rollback is disabled. +requests while rollback is disabled, and [`ThreadSettlementReactor`][settlement] evaluates +server-owned automatic settlement rules. `DrainableWorker` pairs a transactional queue with a transactional count of outstanding items. `enqueue` atomically offers and increments; processing always decrements. `drain` retries until the count reaches zero, so a test can await "queue empty and current item finished" instead of sleeping. -Each of the three services exposes `drain` for exactly this. +Each of these four services exposes `drain` for exactly this. Runtime receipts are a test-only mechanism. `RuntimeReceiptBusLive` in [`RuntimeReceiptBus.ts`][receipts] publishes nothing; only the test layer is PubSub-backed. Do not @@ -152,5 +163,6 @@ already dispatch. [ingest]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts [cmd]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts [checkpoint]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts +[settlement]: ../../apps/server/src/orchestration/ThreadSettlementReactor.ts [receipts]: ../../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts [drivers]: ../../apps/server/src/provider/builtInDrivers.ts diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index aaffc9692..f10e4ecf8 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -11,6 +11,17 @@ shortcut. Mobile unpins immediately. Pinned threads still move to **Settled** when they become inactive. They also move when their pull request merges if **Auto-settle merged threads** is enabled. +Each environment owns its automatic settlement settings. The server checks them even when no web, +desktop, or mobile client is connected. By default, it settles threads after three days without +activity and when their pull request merges. An eligible idle thread also settles when its pull +request closes. An open pull request blocks inactivity settlement. Active work, pending input, and +live background work keep the thread active. T3 Code settles from a closed or merged pull request +only when its timestamp is not older than the user's latest activity. If that timestamp is not +available, the inactivity rule still applies. A manual un-settle also keeps the thread active. +Change these rules in **Settings > General** for the environment. A settings change affects future +settlement and does not reopen a settled thread. Settings saved by older clients on one device no +longer control this behavior. + When you un-settle a thread, it returns to the top of the active list so you can find it right away. Its timestamps do not change. Other threads keep their positions. diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts deleted file mode 100644 index 06a8bb32c..000000000 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ /dev/null @@ -1,587 +0,0 @@ -import { - ProjectId, - ProviderInstanceId, - ThreadId, - TurnId, - type OrchestrationThreadShell, -} from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - canSettle, - changeRequestAutoSettles, - effectiveSettled, - hasQueuedTurnStart, - threadLastActivityAt, - type ChangeRequestStateLike, -} 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 === null ? null : { state }, { autoSettleOnMerge })).toBe( - expected, - ); - }); - - const THREAD_CREATED_AT = "2026-04-01T00:00:00.000Z"; - const idleThread = { - createdAt: THREAD_CREATED_AT, - latestUserMessageAt: null, - latestTurn: null, - }; - - it("ignores a terminal change request last touched before the thread existed", () => { - for (const state of ["merged", "closed"] as const) { - expect( - changeRequestAutoSettles( - { state, updatedAt: "2026-03-31T23:59:59.999Z" }, - { thread: idleThread }, - ), - ).toBe(false); - } - }); - - it("settles on a terminal change request touched at or after the thread's latest event", () => { - for (const updatedAt of [THREAD_CREATED_AT, "2026-04-02T00:00:00.000Z"]) { - expect(changeRequestAutoSettles({ state: "merged", updatedAt }, { thread: idleThread })).toBe( - true, - ); - } - }); - - it("never re-settles a thread revived after the merge", () => { - // Settling on a merge happens once: a user message newer than the PR's - // last activity means the conversation outlived the PR. - const revived = { - createdAt: THREAD_CREATED_AT, - latestUserMessageAt: "2026-04-05T00:00:00.000Z", - latestTurn: null, - }; - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "2026-04-03T00:00:00.000Z" }, - { thread: revived }, - ), - ).toBe(false); - // A merge landing after the revival still settles. - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "2026-04-06T00:00:00.000Z" }, - { thread: revived }, - ), - ).toBe(true); - }); - - it("still settles when the merge lands during an in-flight turn", () => { - // Anchor is user-initiated activity only: the agent finishing a turn - // after the merge must not block the settle the merge earned. - const midTurnMerge = { - createdAt: THREAD_CREATED_AT, - latestUserMessageAt: "2026-04-02T00:00:00.000Z", - latestTurn: { - turnId: TurnId.make("turn-mid"), - state: "completed" as const, - requestedAt: "2026-04-02T00:00:00.000Z", - startedAt: "2026-04-02T00:00:05.000Z", - completedAt: "2026-04-02T00:20:00.000Z", - assistantMessageId: null, - }, - }; - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "2026-04-02T00:10:00.000Z" }, - { thread: midTurnMerge }, - ), - ).toBe(true); - }); - - it("falls back to settling when either timestamp is missing or malformed", () => { - expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(true); - expect( - changeRequestAutoSettles({ state: "merged", updatedAt: null }, { thread: idleThread }), - ).toBe(true); - expect( - changeRequestAutoSettles({ state: "merged", updatedAt: "2026-03-01T00:00:00.000Z" }, {}), - ).toBe(true); - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "not-a-date" }, - { thread: idleThread }, - ), - ).toBe(true); - }); -}); - -function makeShell(input: { - readonly settledOverride?: "settled" | "active" | null; - readonly activityAt: string | null; - readonly sessionStatus?: "starting" | "running"; - readonly pending?: "approval" | "user-input"; -}): OrchestrationThreadShell { - const threadId = ThreadId.make("thread-1"); - 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: null, - worktreePath: null, - latestTurn: - input.activityAt === null - ? null - : { - turnId: TurnId.make("turn-1"), - state: "completed", - requestedAt: input.activityAt, - startedAt: null, - completedAt: null, - assistantMessageId: null, - }, - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: NOW, - archivedAt: null, - settledOverride: input.settledOverride ?? null, - settledAt: input.settledOverride === "settled" ? NOW : null, - session: - input.sessionStatus === undefined - ? null - : { - threadId, - status: input.sessionStatus, - providerName: "Codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: NOW, - }, - latestUserMessageAt: null, - hasPendingApprovals: input.pending === "approval", - hasPendingUserInput: input.pending === "user-input", - hasActionableProposedPlan: false, - }; -} - -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 - ? {} - : { changeRequest: { state: 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, - changeRequest: { state: "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, - changeRequest: { state: changeRequestState }, - }), - ).toBe(true); - } - }); - - it("ignores a change request that merged before the thread's latest event", () => { - // A new thread started at a worktree root inherits the branch's old - // merged PR, and a revived thread outlives its merge; neither settles - // the live conversation. - const fresh = makeShell({ activityAt: FRESH }); - for (const state of ["merged", "closed"] as const) { - expect( - effectiveSettled(fresh, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state, updatedAt: "2026-03-20T00:00:00.000Z" }, - }), - ).toBe(false); - } - // A merge during the thread's life still settles it. - expect( - effectiveSettled(fresh, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state: "merged", updatedAt: "2026-04-09T00:00:00.000Z" }, - }), - ).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, - changeRequest: { state: "merged" }, - }), - ).toBe(false); - - expect( - effectiveSettled(recentlyActive, { - now: NOW, - autoSettleAfterDays: null, - autoSettleOnMerge: false, - changeRequest: { state: "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, - changeRequest: { state: "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, - changeRequest: { state: "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, - changeRequest: { state: "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, - changeRequest: { state: "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, - changeRequest: { state: "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. - const JUST_AFTER = { now: "2026-04-09T12:00:30.000Z" }; - - it("flags a user message no turn has picked up, within the grace window", () => { - const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null }; - expect(hasQueuedTurnStart(noTurn, JUST_AFTER)).toBe(true); - - const staleTurn = { - ...makeShell({ activityAt: FRESH }), - latestUserMessageAt: QUEUED_AT, - }; - expect(hasQueuedTurnStart(staleTurn, JUST_AFTER)).toBe(true); - }); - - it("expires after the grace window: an unadopted message is a failed start, not queued work", () => { - const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null }; - expect(hasQueuedTurnStart(noTurn, { now: "2026-04-09T12:03:00.000Z" })).toBe(false); - // Historical shells (e.g. from servers that never carried latestTurn) - // must never read as queued. - expect(hasQueuedTurnStart(noTurn, { now: NOW })).toBe(false); - }); - - it("clears once a turn adopts the message or the start fails", () => { - const adopted = { - ...makeShell({ activityAt: QUEUED_AT }), - latestUserMessageAt: QUEUED_AT, - }; - expect(hasQueuedTurnStart(adopted, JUST_AFTER)).toBe(false); - - const failed = makeShell({ activityAt: FRESH }); - const failedShell = { - ...failed, - latestUserMessageAt: QUEUED_AT, - session: { - threadId: failed.id, - status: "error" as const, - providerName: "Codex", - runtimeMode: "full-access" as const, - activeTurnId: null, - lastError: "boom", - updatedAt: NOW, - }, - }; - expect(hasQueuedTurnStart(failedShell, JUST_AFTER)).toBe(false); - }); - - it("is quiet without user messages", () => { - expect(hasQueuedTurnStart(makeShell({ activityAt: FRESH }), JUST_AFTER)).toBe(false); - }); - - it("bounds the grace window in both directions: a future-stamped message is skew, not queued work", () => { - // Message timestamps originate on other devices; a clock an hour ahead - // must not hold the queued state for the whole skew. - const skewed = { - latestUserMessageAt: "2026-04-09T13:00:00.000Z", - latestTurn: null, - session: null, - }; - expect(hasQueuedTurnStart(skewed, { now: "2026-04-09T12:00:00.000Z" })).toBe(false); - // A small negative age (within the grace window) still reads as queued. - const slightlyAhead = { - latestUserMessageAt: "2026-04-09T12:00:30.000Z", - latestTurn: null, - session: null, - }; - expect(hasQueuedTurnStart(slightlyAhead, { now: "2026-04-09T12:00:00.000Z" })).toBe(true); - }); -}); - -describe("canSettle", () => { - it("blocks every state effectiveSettled refuses to classify as settled", () => { - expect(canSettle(makeShell({ activityAt: FRESH }), { now: NOW })).toBe(true); - expect( - canSettle(makeShell({ activityAt: FRESH, sessionStatus: "starting" }), { now: NOW }), - ).toBe(false); - expect( - canSettle(makeShell({ activityAt: FRESH, sessionStatus: "running" }), { now: NOW }), - ).toBe(false); - expect(canSettle(makeShell({ activityAt: FRESH, pending: "approval" }), { now: NOW })).toBe( - false, - ); - expect(canSettle(makeShell({ activityAt: FRESH, pending: "user-input" }), { now: NOW })).toBe( - false, - ); - }); - - it("blocks settling a queued turn start, only within the grace window", () => { - const queued = { - ...makeShell({ activityAt: FRESH }), - latestUserMessageAt: "2026-04-09T12:00:00.000Z", - }; - 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, - changeRequest: { state: "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 a8d716536..60b685672 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,100 +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"; - -/** - * The slice of a change request the settle rules need. `updatedAt` is the - * provider's last-activity timestamp; for a merged/closed request it bounds - * when the terminal state landed. - */ -export interface ChangeRequestSettleSource { - readonly state: ChangeRequestStateLike; - readonly updatedAt?: string | null | undefined; -} - -/** What the settle rules need to know about the thread's own timeline. */ -export type ThreadActivitySource = Pick< - OrchestrationThreadShell, - "createdAt" | "latestUserMessageAt" | "latestTurn" ->; - -/** - * Latest USER-initiated activity: messages and the turn requests they start, - * deliberately not the agent-side started/completed stamps. The settle-on- - * merge anchor uses this so a merge landing mid-turn still settles the - * thread when that turn finishes, while a user re-engaging after the merge - * blocks it for good. Falls back to creation time for untouched threads. - */ -function threadUserActivityAnchorAt(thread: ThreadActivitySource): string { - const messageAt = thread.latestUserMessageAt; - const requestedAt = thread.latestTurn?.requestedAt; - let anchor = thread.createdAt; - for (const candidate of [messageAt, requestedAt]) { - if (candidate != null && Date.parse(candidate) > Date.parse(anchor)) { - anchor = candidate; - } - } - return anchor; -} - -/** - * Returns whether the change request settles the thread immediately. A - * terminal request settles the thread only while it postdates every user- - * initiated event in it: settling on a merge happens ONCE. A request last - * touched before the thread was created is inherited branch history (a new - * thread started at a worktree root whose PR already merged), and one older - * than the user's latest engagement was already adjudicated — re-engaging a - * thread whose PR merged is the user saying the conversation outlived the - * PR. Unknown timestamps keep the old always-settle behavior. - */ -export function changeRequestAutoSettles( - changeRequest: ChangeRequestSettleSource | null | undefined, - options: { - readonly autoSettleOnMerge?: boolean | undefined; - readonly thread?: ThreadActivitySource | null | undefined; - } = {}, -): boolean { - if (changeRequest == null) return false; - const terminal = - changeRequest.state === "closed" || - (changeRequest.state === "merged" && options.autoSettleOnMerge !== false); - if (!terminal) return false; - if (changeRequest.updatedAt == null || options.thread == null) return true; - const updatedAtMs = Date.parse(changeRequest.updatedAt); - const anchorAtMs = Date.parse(threadUserActivityAnchorAt(options.thread)); - // Malformed timestamps fall back to settling, matching servers that never - // report updatedAt. - if (Number.isNaN(updatedAtMs) || Number.isNaN(anchorAtMs)) return true; - return updatedAtMs >= anchorAtMs; -} - -const DAY_MS = 24 * 60 * 60 * 1_000; - -export function threadLastActivityAt( - shell: Pick, -): 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 @@ -103,6 +9,7 @@ export function threadLastActivityAt( * such threads would be permanently unsettleable. */ export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; +const DAY_MS = 24 * 60 * 60 * 1_000; /** * A user message no turn has picked up yet: the turn.start command was @@ -137,28 +44,6 @@ 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. - */ -export function canSettle( - shell: Pick< - OrchestrationThreadShell, - "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestUserMessageAt" | "latestTurn" - >, - options: { readonly now: string }, -): boolean { - 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. - if (hasQueuedTurnStart(shell, options)) return false; - return true; -} - /** * The snooze lifecycle fields plus everything needed to detect a raised * hand. Snooze is an overlay on the active state: a snoozed thread stays @@ -181,8 +66,7 @@ export type ThreadSnoozeShell = Pick< * the session failed, or a run completed after the snooze was set — the * 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 thread from classifying as snoozed. */ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean { if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true; @@ -283,79 +167,6 @@ 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 or always on a closed PR (both only while the terminal state is - * the thread's latest event, see changeRequestAutoSettles), 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 changeRequest?: ChangeRequestSettleSource | 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.changeRequest, { - autoSettleOnMerge: options.autoSettleOnMerge, - thread: shell, - }) - ) { - 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.changeRequest?.state === "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 EVENING_HOUR = 18; const MORNING_HOUR = 9; diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 885195da2..a620fa6f6 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -6,12 +6,14 @@ import { describe, expect, it } from "vite-plus/test"; import { canSnooze, effectiveSnoozed, + hasQueuedTurnStart, resolveSnoozePresets, snoozeWakeLabel, threadRaisedHandWhileSnoozed, threadWokeAt, type ThreadSnoozeShell, } from "./threadSettled.ts"; +import type { OrchestrationThreadShell } from "@t3tools/contracts"; const NOW = "2026-04-10T12:00:00.000Z"; const SNOOZED_AT = "2026-04-10T09:00:00.000Z"; @@ -61,6 +63,15 @@ function makeShell(input: { }; } +type QueuedTurnShell = Pick< + OrchestrationThreadShell, + "latestUserMessageAt" | "latestTurn" | "session" +>; + +function makeQueuedTurnShell(overrides: Partial = {}): QueuedTurnShell { + return { latestUserMessageAt: null, latestTurn: null, session: null, ...overrides }; +} + describe("effectiveSnoozed", () => { it("hides a thread whose wake time is in the future", () => { expect(effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE }), { now: NOW })).toBe(true); @@ -202,6 +213,55 @@ describe("canSnooze", () => { }); }); +describe("hasQueuedTurnStart", () => { + it("expires queued state after two minutes", () => { + const thread = makeQueuedTurnShell({ + latestUserMessageAt: "2026-04-10T11:57:59.000Z", + }); + expect(hasQueuedTurnStart(thread, { now: NOW })).toBe(false); + }); + + it("clears queued state when a turn adopts the message or the session fails", () => { + const messageAt = "2026-04-10T11:59:00.000Z"; + const adopted = makeQueuedTurnShell({ + latestUserMessageAt: messageAt, + latestTurn: { + turnId: TurnId.make("turn-adopted"), + state: "running", + requestedAt: messageAt, + startedAt: null, + completedAt: null, + assistantMessageId: null, + }, + }); + const failed = makeQueuedTurnShell({ + latestUserMessageAt: messageAt, + session: { + threadId: ThreadId.make("thread-failed"), + status: "error", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: "failed", + updatedAt: NOW, + }, + }); + expect(hasQueuedTurnStart(adopted, { now: NOW })).toBe(false); + expect(hasQueuedTurnStart(failed, { now: NOW })).toBe(false); + }); + + it("bounds future client clock skew", () => { + const farAhead = makeQueuedTurnShell({ + latestUserMessageAt: "2026-04-10T12:03:00.000Z", + }); + const slightlyAhead = makeQueuedTurnShell({ + latestUserMessageAt: "2026-04-10T12:01:00.000Z", + }); + expect(hasQueuedTurnStart(farAhead, { now: NOW })).toBe(false); + expect(hasQueuedTurnStart(slightlyAhead, { now: NOW })).toBe(true); + }); +}); + describe("threadWokeAt", () => { it("is null for never-snoozed and still-snoozed threads", () => { expect(threadWokeAt(makeShell({}), { now: NOW })).toBe(null); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index a4d74c2e4..82e50d3a5 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -63,6 +63,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ pre-settlement servers, so clients treat missing as unsupported and never send the commands under version skew. */ threadSettlement: Schema.optionalKey(Schema.Boolean), + /** Server evaluates merge and inactivity settlement without a client. */ + threadAutoSettlement: Schema.optionalKey(Schema.Boolean), /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index a92f70dfd..1bdb8f7fb 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1001,6 +1001,13 @@ const ThreadSettleCommand = Schema.Struct({ threadId: ThreadId, }); +const ThreadAutoSettleCommand = Schema.Struct({ + type: Schema.Literal("thread.auto-settle"), + commandId: CommandId, + threadId: ThreadId, + snapshotSequence: NonNegativeInt, +}); + const ThreadUnsettleCommand = Schema.Struct({ type: Schema.Literal("thread.unsettle"), commandId: CommandId, @@ -1458,6 +1465,7 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ }); const InternalOrchestrationCommand = Schema.Union([ + ThreadAutoSettleCommand, ThreadSessionSetCommand, ThreadSessionApplyLifecycleCommand, ThreadSessionBindPendingCommand, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 1203e7a71..4d3867752 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -148,11 +148,8 @@ describe("ClientSettings environment identification", () => { }); describe("ClientSettings sidebar", () => { - it("defaults to the current sidebar with automatic merge and inactivity settling", () => { - const settings = decodeClientSettings({}); - expect(settings.legacySidebarEnabled).toBe(false); - expect(settings.sidebarAutoSettleAfterDays).toBe(3); - expect(settings.sidebarAutoSettleOnMerge).toBe(true); + it("defaults to the current sidebar", () => { + expect(decodeClientSettings({}).legacySidebarEnabled).toBe(false); }); it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => { @@ -177,25 +174,33 @@ describe("ClientSettings sidebar", () => { expect(decodeClientSettingsPatch({ confirmThreadUnpin: true }).confirmThreadUnpin).toBe(true); expect(() => decodeClientSettingsPatch({ confirmThreadUnpin: "yes" })).toThrow(); }); +}); - it("allows auto-settle by inactivity to be disabled", () => { - expect( - decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, - ).toBeNull(); +describe("ServerSettings thread settlement", () => { + it("defaults merge settlement on and inactivity settlement to three days", () => { + const settings = decodeServerSettings({}); + expect(settings.sidebarAutoSettleAfterDays).toBe(3); + expect(settings.sidebarAutoSettleOnMerge).toBe(true); }); - it("allows auto-settle on merge to be disabled", () => { - expect(decodeClientSettings({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge).toBe( - false, - ); + it("allows both automatic rules to be disabled", () => { expect( - decodeClientSettingsPatch({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge, - ).toBe(false); + decodeServerSettings({ + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + }), + ).toMatchObject({ sidebarAutoSettleAfterDays: null, sidebarAutoSettleOnMerge: false }); + expect( + decodeServerSettingsPatch({ + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + }), + ).toMatchObject({ sidebarAutoSettleAfterDays: null, sidebarAutoSettleOnMerge: 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({ sidebarAutoSettleAfterDays: value })).toThrow(); + expect(() => decodeServerSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow(); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0044e8f33..7b2a4ed35 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -254,10 +254,6 @@ export const ClientSettingsSchema = Schema.Struct({ // flag restores it along with the /plan and /default slash commands. Pylon // Mobile already carries a device-local counterpart of this key. planModeEnabled: 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)), ), @@ -712,6 +708,10 @@ export const ServerSettings = Schema.Struct({ * between a desktop window and a phone attached to the same server. */ enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), + ), + sidebarAutoSettleOnMerge: 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. @@ -948,6 +948,8 @@ export const ServerSettingsPatch = Schema.Struct({ enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), + sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), + sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( Schema.Struct({ schemaVersion: Schema.optionalKey(Schema.Literal(1)), @@ -1089,8 +1091,6 @@ export const ClientSettingsPatch = Schema.Struct({ showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), planModeEnabled: 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), From 417f9cf3c52488dfa1c25078cf4b4323f682d679 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Wed, 2 Sep 2026 14:34:26 -0600 Subject: [PATCH 2/2] fix(server): complete the settle-threads adoption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups the cherry-pick did not carry. #8600 rewrites isUnpublishedBranch to treat a preserved branch..remote and .merge pair as evidence the branch was published. That hunk applied to neither a conflict nor a type error — it simply did not land, leaving the old remote-refs-only check. Its own test caught it. With the rewrite in place, Pylon's pruned-branch skip is no longer the right answer: prune removes the tracking ref but leaves the config, so the branch is now distinguishable from one that was never published and the lookup should run rather than fall back to the last-known PR. Pylon's test asserted the saved API call; it now asserts the live one. ws.ts keeps upstream's narrowing from parkingCommand back to archiveCommand. That is not a lost Pylon feature — upstream had parkingCommand too, and #8600 moves settle cleanup into ProviderCommandReactor's thread.settled handler, which dispatches the same onlyIfSettled stop and also covers settlements with no client attached. --- apps/server/src/git/GitManager.test.ts | 27 +++++++++++++++++++------- apps/server/src/git/GitManager.ts | 27 ++++++++++++++++++-------- docs/user/thread-sidebar.md | 2 +- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 09d821532..86606947d 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1523,6 +1523,19 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-04-02T10:00:00Z", }, ]), + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 512, + title: "Merged then pruned", + url: "https://github.com/pingdotgg/t3code/pull/512", + baseRefName: "main", + headRefName: "feature/merged-then-pruned", + state: "MERGED", + mergedAt: "2026-04-02T10:00:00Z", + updatedAt: "2026-04-02T10:00:00Z", + }, + ]), ], }, }); @@ -1532,11 +1545,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(first.pr?.state).toBe("merged"); // Merging on the host deletes the remote head, and a prune drops the - // remote-tracking ref along with `@{upstream}`. From remote refs alone - // that is indistinguishable from a branch that was never published, so - // the unpublished-branch skip fires here. Skipping means "we did not - // ask", not "there is no PR": blanking the badge would also deny - // auto-settle the merged state it waits for. + // remote-tracking ref along with `@{upstream}`. #8600 made that + // distinguishable from a never-published branch: prune leaves + // `branch..remote` and `.merge` behind as evidence the branch was + // published, so the lookup runs instead of skipping and the badge comes + // from live state rather than the last-known fallback. yield* runGit(repoDir, ["push", "origin", "--delete", "feature/merged-then-pruned"]); yield* runGit(repoDir, ["fetch", "--prune", "origin"]); @@ -1545,8 +1558,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(second.pr?.number).toBe(512); expect(second.pr?.state).toBe("merged"); - // The saved API call is the point of the skip, so it must still be saved. - expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); + // The branch is now identifiable as published, so asking again is correct. + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); }), ); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 004dcff2d..e737fc6e0 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1406,7 +1406,7 @@ export const make = Effect.gen(function* () { */ const isUnpublishedBranch = Effect.fn("isUnpublishedBranch")(function* ( cwd: string, - headContext: Pick, + headContext: Pick, ) { if (headContext.headBranch.length === 0) { return false; @@ -1421,13 +1421,24 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.map((result) => result.stdout.trim().length > 0)); - return yield* Effect.all( - [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)], - { concurrency: "unbounded" }, - ).pipe( - Effect.map(([tracksAnyRemote, tracksThisBranch]) => tracksAnyRemote && !tracksThisBranch), - Effect.orElseSucceed(() => false), - ); + return yield* Effect.gen(function* () { + const [configuredRemote, configuredMerge] = yield* Effect.all( + [ + gitCore.readConfigValue(cwd, `branch.${headContext.localBranch}.remote`), + gitCore.readConfigValue(cwd, `branch.${headContext.localBranch}.merge`), + ], + { concurrency: "unbounded" }, + ); + if (configuredRemote !== null && configuredMerge !== null) { + return false; + } + + const [tracksAnyRemote, tracksThisBranch] = yield* Effect.all( + [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)], + { concurrency: "unbounded" }, + ); + return tracksAnyRemote && !tracksThisBranch; + }).pipe(Effect.orElseSucceed(() => false)); }); const findOpenPr = Effect.fn("findOpenPr")(function* ( diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index f10e4ecf8..5c75441df 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -15,7 +15,7 @@ Each environment owns its automatic settlement settings. The server checks them desktop, or mobile client is connected. By default, it settles threads after three days without activity and when their pull request merges. An eligible idle thread also settles when its pull request closes. An open pull request blocks inactivity settlement. Active work, pending input, and -live background work keep the thread active. T3 Code settles from a closed or merged pull request +live background work keep the thread active. Pylon settles from a closed or merged pull request only when its timestamp is not older than the user's latest activity. If that timestamp is not available, the inactivity rule still applies. A manual un-settle also keeps the thread active. Change these rules in **Settings > General** for the environment. A settings change affects future