authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-01 03:24:24-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 03:24:24-07:00
logf32f9a2f41342bf8a1a109d6ebe9b70044c5311b
tree653a83aa72dc2b3f76d0f95d9d61e73f6cd2538e
parent7e4ce3bbb16c3cfa1ea756de917d8f9398e5999e
signature Signed by PGP key B5690EEEBB952194

fix(server): settle threads server-side (#8600)


55 files changed, 2593 insertions(+), 1658 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts-2
...@@ -40,8 +40,6 @@ const clientSettings: ClientSettings = {...@@ -40,8 +40,6 @@ const clientSettings: ClientSettings = {
40 planModeEnabled: false,40 planModeEnabled: false,
41 showSkillsInSlashMenu: false,41 showSkillsInSlashMenu: false,
42 providerModelPreferences: {},42 providerModelPreferences: {},
43 sidebarAutoSettleAfterDays: 3,
44 sidebarAutoSettleOnMerge: true,
45 sidebarProjectGroupingMode: "repository_path",43 sidebarProjectGroupingMode: "repository_path",
46 sidebarProjectGroupingOverrides: {44 sidebarProjectGroupingOverrides: {
47 "environment-1:/tmp/project-a": "separate",45 "environment-1:/tmp/project-a": "separate",
apps/mobile/src/features/home/HomeScreen.tsx+3-44
...@@ -53,7 +53,6 @@ import {...@@ -53,7 +53,6 @@ import {
53 buildThreadListV2ListItems,53 buildThreadListV2ListItems,
54 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,54 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
55 THREAD_LIST_V2_SETTLED_PAGE_COUNT,55 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
56 type ThreadListV2ChangeRequestState,
57 type ThreadListV2ListItem,56 type ThreadListV2ListItem,
58} from "../threads/threadListV2";57} from "../threads/threadListV2";
59import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";58import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
...@@ -209,9 +208,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -209,9 +208,6 @@ export function HomeScreen(props: HomeScreenProps) {
209 >(() => new Map());208 >(() => new Map());
210 const preferencesResult = useAtomValue(mobilePreferencesAtom);209 const preferencesResult = useAtomValue(mobilePreferencesAtom);
211 const threadListV2Enabled = useThreadListV2Enabled();210 const threadListV2Enabled = useThreadListV2Enabled();
212 const autoSettleOnMerge =
213 !AsyncResult.isSuccess(preferencesResult) ||
214 preferencesResult.value.autoSettleOnMerge !== false;
215 const savePreferences = useAtomSet(updateMobilePreferencesAtom);211 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
216 const openSwipeableRef = useRef<SwipeableMethods | null>(null);212 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
217 const listRef = useRef<LegendListRef | null>(null);213 const listRef = useRef<LegendListRef | null>(null);
...@@ -487,33 +483,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -487,33 +483,6 @@ export function HomeScreen(props: HomeScreenProps) {
487 // Settled threads stay in the live shell stream (settled ≠ archived), so483 // Settled threads stay in the live shell stream (settled ≠ archived), so
488 // the partition works directly off live shells — no snapshot merging or484 // the partition works directly off live shells — no snapshot merging or
489 // optimistic holds.485 // optimistic holds.
490 // PR states stream in per-row. The next partition applies the configured
491 // merge rule and the always-on close rule, matching web.
492 const [changeRequestByKey, setChangeRequestByKey] = useState<
493 ReadonlyMap<string, ThreadListV2ChangeRequestState>
494 >(() => new Map());
495 const handleChangeRequestState = useCallback(
496 (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
497 setChangeRequestByKey((current) => {
498 const existing = current.get(threadKey) ?? null;
499 if (
500 (existing?.state ?? null) === (changeRequest?.state ?? null) &&
501 (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
502 (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
503 ) {
504 return current;
505 }
506 const next = new Map(current);
507 if (changeRequest === null) {
508 next.delete(threadKey);
509 } else {
510 next.set(threadKey, changeRequest);
511 }
512 return next;
513 });
514 },
515 [],
516 );
517 const handleSettleThread = useCallback(486 const handleSettleThread = useCallback(
518 (thread: EnvironmentThreadShell) => {487 (thread: EnvironmentThreadShell) => {
519 void props.onSettleThread(thread);488 void props.onSettleThread(thread);
...@@ -580,9 +549,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -580,9 +549,7 @@ export function HomeScreen(props: HomeScreenProps) {
580 toggleSettledShelf,549 toggleSettledShelf,
581 toggleSnoozedShelf,550 toggleSnoozedShelf,
582 } = useThreadListV2ShelfPreferences();551 } = useThreadListV2ShelfPreferences();
583 // now is quantized to the minute and ticks so the inactivity auto-settle552 // The queued-start and snooze helpers need a clock while the list stays open.
584 // boundary is actually crossed while the app stays open (mirrors web);
585 // without a clock dependency the partition memoizes a frozen "now".
586 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));553 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
587 // Snooze wake times are second-precise; a counter bumped exactly at the554 // Snooze wake times are second-precise; a counter bumped exactly at the
588 // next wake boundary re-runs the partition with a fresh clock so a woken555 // next wake boundary re-runs the partition with a fresh clock so a woken
...@@ -591,8 +558,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -591,8 +558,7 @@ export function HomeScreen(props: HomeScreenProps) {
591 useFocusEffect(558 useFocusEffect(
592 useCallback(() => {559 useCallback(() => {
593 if (!threadListV2Enabled) return;560 if (!threadListV2Enabled) return;
594 // Refresh immediately on enable or focus: the previous value can be hours561 // Refresh immediately on enable or focus because the previous value can be hours old.
595 // old and misclassify the inactivity auto-settle boundary until the first tick.
596 setNowMinute(new Date().toISOString().slice(0, 16));562 setNowMinute(new Date().toISOString().slice(0, 16));
597 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);563 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
598 return () => clearInterval(id);564 return () => clearInterval(id);
...@@ -679,20 +645,15 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -679,20 +645,15 @@ export function HomeScreen(props: HomeScreenProps) {
679 projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,645 projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
680 searchQuery: props.searchQuery,646 searchQuery: props.searchQuery,
681 matchedThreadKeys,647 matchedThreadKeys,
682 changeRequestByKey,
683 autoSettleOnMerge,
684 settlementEnvironmentIds,648 settlementEnvironmentIds,
685 snoozeEnvironmentIds,649 snoozeEnvironmentIds,
686 settledLimit: settledVisibleCount,650 settledLimit: settledVisibleCount,
687 now: `${nowMinute}:00.000Z`,651 now: new Date().toISOString(),
688 snoozeNow: new Date().toISOString(),
689 snoozedShelfExpanded,652 snoozedShelfExpanded,
690 settledShelfExpanded,653 settledShelfExpanded,
691 selectedThreadKey: null,654 selectedThreadKey: null,
692 });655 });
693 }, [656 }, [
694 changeRequestByKey,
695 autoSettleOnMerge,
696 nowMinute,657 nowMinute,
697 snoozeWakeTick,658 snoozeWakeTick,
698 snoozedShelfExpanded,659 snoozedShelfExpanded,
...@@ -864,7 +825,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -864,7 +825,6 @@ export function HomeScreen(props: HomeScreenProps) {
864 onPinThread={handlePinThread}825 onPinThread={handlePinThread}
865 onUnpinThread={handleUnpinThread}826 onUnpinThread={handleUnpinThread}
866 onMovePinnedThread={handleMovePinnedThread}827 onMovePinnedThread={handleMovePinnedThread}
867 onChangeRequestState={handleChangeRequestState}
868 projectCwd={828 projectCwd={
869 projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null829 projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null
870 }830 }
...@@ -874,7 +834,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -874,7 +834,6 @@ export function HomeScreen(props: HomeScreenProps) {
874 );834 );
875 },835 },
876 [836 [
877 handleChangeRequestState,
878 handleDeleteThread,837 handleDeleteThread,
879 arrangedPinnedKeys,838 arrangedPinnedKeys,
880 handleMovePinnedThread,839 handleMovePinnedThread,
apps/mobile/src/features/home/useThreadListActions.ts+1-11
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled";2import { canSnooze } from "@t3tools/client-runtime/state/thread-settled";
3import * as Cause from "effect/Cause";3import * as Cause from "effect/Cause";
4import * as Haptics from "expo-haptics";4import * as Haptics from "expo-haptics";
5import { useCallback, useRef } from "react";5import { useCallback, useRef } from "react";
...@@ -118,16 +118,6 @@ function useThreadActionExecutor(...@@ -118,16 +118,6 @@ function useThreadActionExecutor(
118 );118 );
119 return false;119 return false;
120 }120 }
121 // Settle may only target what effectiveSettled could classify as
122 // settled: not starting/running sessions, not threads waiting on
123 // approvals or user input. Anything else would hide live work.
124 if (action === "settle" && !canSettle(thread, { now: new Date().toISOString() })) {
125 Alert.alert(
126 actionFailureTitle(action),
127 "This thread still needs attention. Resolve or interrupt it first, then try again.",
128 );
129 return false;
130 }
131 // Archive keeps its original, narrower guard: never interrupt a121 // Archive keeps its original, narrower guard: never interrupt a
132 // thread mid-turn.122 // thread mid-turn.
133 if (123 if (
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+42-11
...@@ -34,6 +34,9 @@ import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";...@@ -34,6 +34,9 @@ import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
34import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";34import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
35import { runtime } from "../../lib/runtime";35import { runtime } from "../../lib/runtime";
36import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";36import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
37import { serverEnvironment } from "../../state/server";
38import { useAtomCommand } from "../../state/use-atom-command";
39import type { EnvironmentId } from "@t3tools/contracts";
37import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";40import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
38import {41import {
39 type AppUpdateCheckState,42 type AppUpdateCheckState,
...@@ -527,26 +530,54 @@ function ConfiguredSettingsRouteScreen() {...@@ -527,26 +530,54 @@ function ConfiguredSettingsRouteScreen() {
527}530}
528531
529function GeneralSettingsSection() {532function GeneralSettingsSection() {
530 const preferencesResult = useAtomValue(mobilePreferencesAtom);533 const { savedConnectionsById } = useSavedRemoteConnections();
531 const savePreferences = useAtomSet(updateMobilePreferencesAtom);534 const connections = Object.values(savedConnectionsById).sort((left, right) =>
532 const autoSettleOnMerge =535 left.environmentLabel.localeCompare(right.environmentLabel),
533 !AsyncResult.isSuccess(preferencesResult) ||536 );
534 preferencesResult.value.autoSettleOnMerge !== false;
535537
536 return (538 return (
537 <SettingsSection title="General">539 <SettingsSection title="General">
538 <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />540 <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
539 <SettingsSwitchRow541 {connections.map((connection) => (
540 icon="arrow.triangle.branch"542 <EnvironmentAutoSettleSwitch
541 label="Auto-settle merged threads"543 key={connection.environmentId}
542 value={autoSettleOnMerge}544 environmentId={connection.environmentId}
543 onValueChange={(value) => savePreferences({ autoSettleOnMerge: value })}545 environmentLabel={connection.environmentLabel}
544 />546 />
547 ))}
545 <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />548 <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
546 </SettingsSection>549 </SettingsSection>
547 );550 );
548}551}
549552
553function EnvironmentAutoSettleSwitch(props: {
554 readonly environmentId: EnvironmentId;
555 readonly environmentLabel: string;
556}) {
557 const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId));
558 const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId));
559 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
560 label: "auto-settle settings update",
561 reportFailure: true,
562 });
563 if (config?.environment.capabilities.threadAutoSettlement !== true || settings === null) {
564 return null;
565 }
566 return (
567 <SettingsSwitchRow
568 icon="arrow.triangle.branch"
569 label={`Auto-settle merged threads · ${props.environmentLabel}`}
570 value={settings?.sidebarAutoSettleOnMerge ?? true}
571 onValueChange={(value) => {
572 void updateSettings({
573 environmentId: props.environmentId,
574 input: { patch: { sidebarAutoSettleOnMerge: value } },
575 });
576 }}
577 />
578 );
579}
580
550/**581/**
551 * Device-local legacy toggles. Mobile has no client-settings sync, so this is582 * Device-local legacy toggles. Mobile has no client-settings sync, so this is
552 * the counterpart of web's Settings → General → Legacy features backed by583 * the counterpart of web's Settings → General → Legacy features backed by
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+3-48
...@@ -9,7 +9,6 @@ import {...@@ -9,7 +9,6 @@ import {
9import { LegendList } from "@legendapp/list/react-native";9import { LegendList } from "@legendapp/list/react-native";
10import type { MenuAction } from "@react-native-menu/menu";10import type { MenuAction } from "@react-native-menu/menu";
11import { useAtomValue } from "@effect/atom-react";11import { useAtomValue } from "@effect/atom-react";
12import { AsyncResult } from "effect/unstable/reactivity";
13import type { EnvironmentId } from "@t3tools/contracts";12import type { EnvironmentId } from "@t3tools/contracts";
14import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";13import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
15import { useCallback, useEffect, useMemo, useRef, useState } from "react";14import { useCallback, useEffect, useMemo, useRef, useState } from "react";
...@@ -28,7 +27,6 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";...@@ -28,7 +27,6 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
28import { NativeStackScreenOptions } from "../../native/StackHeader";27import { NativeStackScreenOptions } from "../../native/StackHeader";
29import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";28import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
30import { useProjects, useThreadShells } from "../../state/entities";29import { useProjects, useThreadShells } from "../../state/entities";
31import { mobilePreferencesAtom } from "../../state/preferences";
32import { useThreadSearch } from "../../state/queries";30import { useThreadSearch } from "../../state/queries";
33import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";31import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
34import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";32import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
...@@ -82,7 +80,6 @@ import {...@@ -82,7 +80,6 @@ import {
82 buildThreadListV2ListItems,80 buildThreadListV2ListItems,
83 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,81 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
84 THREAD_LIST_V2_SETTLED_PAGE_COUNT,82 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
85 type ThreadListV2ChangeRequestState,
86 type ThreadListV2ListItem,83 type ThreadListV2ListItem,
87} from "./threadListV2";84} from "./threadListV2";
8885
...@@ -164,10 +161,6 @@ function ThreadNavigationSidebarPane(...@@ -164,10 +161,6 @@ function ThreadNavigationSidebarPane(
164 regenerateThreadTitle,161 regenerateThreadTitle,
165 } = useThreadListActions();162 } = useThreadListActions();
166 const threadListV2Enabled = useThreadListV2Enabled();163 const threadListV2Enabled = useThreadListV2Enabled();
167 const preferencesResult = useAtomValue(mobilePreferencesAtom);
168 const autoSettleOnMerge =
169 !AsyncResult.isSuccess(preferencesResult) ||
170 preferencesResult.value.autoSettleOnMerge !== false;
171 const pendingTasks = usePendingNewTasks();164 const pendingTasks = usePendingNewTasks();
172 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();165 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
173 const environments = useMemo(166 const environments = useMemo(
...@@ -365,33 +358,6 @@ function ThreadNavigationSidebarPane(...@@ -365,33 +358,6 @@ function ThreadNavigationSidebarPane(
365358
366 // Thread List v2 (beta) support — same model as the compact Home list359 // Thread List v2 (beta) support — same model as the compact Home list
367 // (HomeScreen.tsx): flat creation-order card block + settled recency tail.360 // (HomeScreen.tsx): flat creation-order card block + settled recency tail.
368 // PR states stream in per-row. The next partition applies the configured
369 // merge rule and the always-on close rule.
370 const [changeRequestByKey, setChangeRequestByKey] = useState<
371 ReadonlyMap<string, ThreadListV2ChangeRequestState>
372 >(() => new Map());
373 const handleChangeRequestState = useCallback(
374 (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => {
375 setChangeRequestByKey((current) => {
376 const existing = current.get(threadKey) ?? null;
377 if (
378 (existing?.state ?? null) === (changeRequest?.state ?? null) &&
379 (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) &&
380 (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null)
381 ) {
382 return current;
383 }
384 const next = new Map(current);
385 if (changeRequest === null) {
386 next.delete(threadKey);
387 } else {
388 next.set(threadKey, changeRequest);
389 }
390 return next;
391 });
392 },
393 [],
394 );
395 // The settled tail renders in pages; expansion resets when the filter361 // The settled tail renders in pages; expansion resets when the filter
396 // context changes so environment/search flips never inherit a deep page.362 // context changes so environment/search flips never inherit a deep page.
397 const [settledVisibleCount, setSettledVisibleCount] = useState(363 const [settledVisibleCount, setSettledVisibleCount] = useState(
...@@ -414,9 +380,7 @@ function ThreadNavigationSidebarPane(...@@ -414,9 +380,7 @@ function ThreadNavigationSidebarPane(
414 toggleSettledShelf,380 toggleSettledShelf,
415 toggleSnoozedShelf,381 toggleSnoozedShelf,
416 } = useThreadListV2ShelfPreferences();382 } = useThreadListV2ShelfPreferences();
417 // now ticks per minute so the inactivity auto-settle boundary is actually383 // The queued-start and snooze helpers need a clock while the pane stays open.
418 // crossed while the pane stays open; without a clock dependency the
419 // partition memoizes a frozen "now".
420 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));384 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
421 // Snooze wake times are second-precise; a counter bumped exactly at the385 // Snooze wake times are second-precise; a counter bumped exactly at the
422 // next wake boundary re-runs the partition with a fresh clock so a woken386 // next wake boundary re-runs the partition with a fresh clock so a woken
...@@ -424,9 +388,7 @@ function ThreadNavigationSidebarPane(...@@ -424,9 +388,7 @@ function ThreadNavigationSidebarPane(
424 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);388 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
425 useEffect(() => {389 useEffect(() => {
426 if (!threadListV2Enabled) return;390 if (!threadListV2Enabled) return;
427 // Refresh immediately on enable: the mount-time value can be hours old391 // Refresh immediately because the mount-time value can be hours old.
428 // by the time the beta is switched on, which would misclassify the
429 // inactivity auto-settle boundary until the first tick.
430 setNowMinute(new Date().toISOString().slice(0, 16));392 setNowMinute(new Date().toISOString().slice(0, 16));
431 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);393 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
432 return () => clearInterval(id);394 return () => clearInterval(id);
...@@ -509,20 +471,15 @@ function ThreadNavigationSidebarPane(...@@ -509,20 +471,15 @@ function ThreadNavigationSidebarPane(
509 projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,471 projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,
510 searchQuery: props.searchQuery,472 searchQuery: props.searchQuery,
511 matchedThreadKeys,473 matchedThreadKeys,
512 changeRequestByKey,
513 autoSettleOnMerge,
514 settlementEnvironmentIds,474 settlementEnvironmentIds,
515 snoozeEnvironmentIds,475 snoozeEnvironmentIds,
516 settledLimit: settledVisibleCount,476 settledLimit: settledVisibleCount,
517 now: `${nowMinute}:00.000Z`,477 now: new Date().toISOString(),
518 snoozeNow: new Date().toISOString(),
519 snoozedShelfExpanded,478 snoozedShelfExpanded,
520 settledShelfExpanded,479 settledShelfExpanded,
521 selectedThreadKey: props.selectedThreadKey ?? null,480 selectedThreadKey: props.selectedThreadKey ?? null,
522 });481 });
523 }, [482 }, [
524 changeRequestByKey,
525 autoSettleOnMerge,
526 nowMinute,483 nowMinute,
527 snoozeWakeTick,484 snoozeWakeTick,
528 snoozedShelfExpanded,485 snoozedShelfExpanded,
...@@ -931,7 +888,6 @@ function ThreadNavigationSidebarPane(...@@ -931,7 +888,6 @@ function ThreadNavigationSidebarPane(
931 onPinThread={pinThread}888 onPinThread={pinThread}
932 onUnpinThread={unpinThread}889 onUnpinThread={unpinThread}
933 onMovePinnedThread={movePinnedThread}890 onMovePinnedThread={movePinnedThread}
934 onChangeRequestState={handleChangeRequestState}
935 projectCwd={projectCwdByKey.get(scopeKey) ?? null}891 projectCwd={projectCwdByKey.get(scopeKey) ?? null}
936 onSwipeableClose={handleSwipeableClose}892 onSwipeableClose={handleSwipeableClose}
937 onSwipeableWillOpen={handleSwipeableWillOpen}893 onSwipeableWillOpen={handleSwipeableWillOpen}
...@@ -1058,7 +1014,6 @@ function ThreadNavigationSidebarPane(...@@ -1058,7 +1014,6 @@ function ThreadNavigationSidebarPane(
1058 arrangedPinnedKeys,1014 arrangedPinnedKeys,
1059 confirmDeletePendingTask,1015 confirmDeletePendingTask,
1060 confirmDeleteThread,1016 confirmDeleteThread,
1061 handleChangeRequestState,
1062 handleSelectThread,1017 handleSelectThread,
1063 handleSwipeableClose,1018 handleSwipeableClose,
1064 handleSwipeableWillOpen,1019 handleSwipeableWillOpen,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+2-24
...@@ -23,12 +23,10 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";...@@ -23,12 +23,10 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";
23import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";23import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
24import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";24import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
25import {25import {
26 resolveThreadListV2ChangeRequestState,
27 resolveThreadListV2SnoozeMenuSelection,26 resolveThreadListV2SnoozeMenuSelection,
28 resolveThreadListV2SnoozeGateExpiryMs,27 resolveThreadListV2SnoozeGateExpiryMs,
29 resolveThreadListV2Status,28 resolveThreadListV2Status,
30 resolveThreadListV2SwipeActions,29 resolveThreadListV2SwipeActions,
31 type ThreadListV2ChangeRequestState,
32 type ThreadListV2Status,30 type ThreadListV2Status,
33} from "./threadListV2";31} from "./threadListV2";
34import { ThreadSearchMatchExcerpt } from "./thread-search-match";32import { ThreadSearchMatchExcerpt } from "./thread-search-match";
...@@ -370,12 +368,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -370,12 +368,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
370 readonly canMovePinnedDown?: boolean;368 readonly canMovePinnedDown?: boolean;
371 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;369 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
372 readonly onSwipeableClose: (methods: SwipeableMethods) => void;370 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
373 /** Reports this row's live PR (state + last activity) for the partition's
374 merge and close rules. Mirrors web's onChangeRequestState. */
375 readonly onChangeRequestState?: (
376 threadKey: string,
377 changeRequest: ThreadListV2ChangeRequestState | null,
378 ) => void;
379 readonly projectCwd?: string | null;371 readonly projectCwd?: string | null;
380 readonly searchMatch?: EnvironmentThreadSearchMatch;372 readonly searchMatch?: EnvironmentThreadSearchMatch;
381 readonly searchQuery?: string;373 readonly searchQuery?: string;
...@@ -398,24 +390,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -398,24 +390,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
398 onPinThread,390 onPinThread,
399 onUnpinThread,391 onUnpinThread,
400 onMovePinnedThread,392 onMovePinnedThread,
401 onChangeRequestState,
402 } = props;393 } = props;
403 const snoozedRow = props.snoozed === true;394 const snoozedRow = props.snoozed === true;
404 const pinnedRow = props.pinned === true;395 const pinnedRow = props.pinned === true;
405396
406 const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);397 const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);
407 const prState = pr?.state ?? null;
408 const prUpdatedAt = pr?.updatedAt ?? null;
409 const threadKey = `${thread.environmentId}:${thread.id}`;
410 useEffect(() => {
411 const changeRequest = resolveThreadListV2ChangeRequestState({
412 linkedPullRequest: thread.linkedPullRequest,
413 state: prState,
414 updatedAt: prUpdatedAt,
415 });
416 if (changeRequest === undefined) return;
417 onChangeRequestState?.(threadKey, changeRequest);
418 }, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]);
419398
420 const theme = useUniwindTheme();399 const theme = useUniwindTheme();
421 const screenColor = theme["--color-screen"];400 const screenColor = theme["--color-screen"];
...@@ -453,9 +432,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -453,9 +432,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
453 );432 );
454 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);433 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
455434
456 // Swipe: the v2 primary action is the lifecycle transition. Every settled435 // Swipe: the v2 primary action is the lifecycle transition. Un-settling a
457 // row can un-settle — explicit settles clear the override, auto-settled436 // settled row keeps it active until new activity clears the user override.
458 // rows get pinned active until real activity clears the pin.
459 const canUnsettle = variant === "slim";437 const canUnsettle = variant === "slim";
460 const [snoozeGateTick, bumpSnoozeGateTick] = useState(0);438 const [snoozeGateTick, bumpSnoozeGateTick] = useState(0);
461 const snoozeGateExpiryMs = props.snoozeSupported439 const snoozeGateExpiryMs = props.snoozeSupported
apps/mobile/src/features/threads/threadListV2.test.ts+10-151
...@@ -16,7 +16,6 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";...@@ -16,7 +16,6 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
16import {16import {
17 buildThreadListV2Items,17 buildThreadListV2Items,
18 buildThreadListV2ListItems,18 buildThreadListV2ListItems,
19 resolveThreadListV2ChangeRequestState,
20 resolveThreadListV2Enabled,19 resolveThreadListV2Enabled,
21 resolveThreadListV2SnoozeMenuSelection,20 resolveThreadListV2SnoozeMenuSelection,
22 resolveThreadListV2SnoozeGateExpiryMs,21 resolveThreadListV2SnoozeGateExpiryMs,
...@@ -61,42 +60,6 @@ const linkedPullRequest = {...@@ -61,42 +60,6 @@ const linkedPullRequest = {
61 url: "https://github.com/pingdotgg/t3code/pull/42",60 url: "https://github.com/pingdotgg/t3code/pull/42",
62};61};
6362
64describe("resolveThreadListV2ChangeRequestState", () => {
65 it("preserves the previous state while a linked pull request reloads", () => {
66 expect(
67 resolveThreadListV2ChangeRequestState({
68 linkedPullRequest,
69 state: null,
70 updatedAt: null,
71 }),
72 ).toBeUndefined();
73 });
74
75 it("clears the previous state after a pull request is unlinked", () => {
76 expect(
77 resolveThreadListV2ChangeRequestState({
78 linkedPullRequest: null,
79 state: null,
80 updatedAt: null,
81 }),
82 ).toBeNull();
83 });
84
85 it("reports a loaded linked pull request", () => {
86 expect(
87 resolveThreadListV2ChangeRequestState({
88 linkedPullRequest,
89 state: "merged",
90 updatedAt: "2026-06-02T00:00:00.000Z",
91 }),
92 ).toEqual({
93 state: "merged",
94 updatedAt: "2026-06-02T00:00:00.000Z",
95 linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
96 });
97 });
98});
99
100describe("resolveThreadListV2SnoozeMenuSelection", () => {63describe("resolveThreadListV2SnoozeMenuSelection", () => {
101 it("accepts a displayed evening preset while its wake time is still future", () => {64 it("accepts a displayed evening preset while its wake time is still future", () => {
102 const menuOpenedAt = new Date(2026, 4, 8, 16, 59, 30);65 const menuOpenedAt = new Date(2026, 4, 8, 16, 59, 30);
...@@ -319,51 +282,18 @@ describe("sortThreadsForListV2", () => {...@@ -319,51 +282,18 @@ describe("sortThreadsForListV2", () => {
319});282});
320283
321describe("buildThreadListV2Items", () => {284describe("buildThreadListV2Items", () => {
322 it("ignores the previous pull request state after a different pull request is linked", () => {285 it("places a persisted settled thread in the settled shelf", () => {
323 const thread = makeThread({
324 id: ThreadId.make("linked"),
325 title: "Linked pull request",
326 linkedPullRequest,
327 });
328 const layout = buildThreadListV2Items({
329 threads: [thread],
330 environmentId: null,
331 searchQuery: "",
332 changeRequestByKey: new Map([
333 [
334 `${environmentId}:${thread.id}`,
335 {
336 state: "merged" as const,
337 linkedPullRequestKey: '["project-1","pingdotgg/t3code",41]',
338 },
339 ],
340 ]),
341 now: NOW,
342 });
343
344 expect(layout.settledCount).toBe(0);
345 expect(layout.items[0]?.variant).toBe("card");
346 });
347
348 it("settles a thread only when the cached pull request identity matches", () => {
349 const thread = makeThread({286 const thread = makeThread({
350 id: ThreadId.make("linked-merged"),287 id: ThreadId.make("linked-merged"),
351 title: "Linked merged pull request",288 title: "Linked merged pull request",
352 linkedPullRequest,289 linkedPullRequest,
290 settledOverride: "settled",
291 settledAt: NOW,
353 });292 });
354 const layout = buildThreadListV2Items({293 const layout = buildThreadListV2Items({
355 threads: [thread],294 threads: [thread],
356 environmentId: null,295 environmentId: null,
357 searchQuery: "",296 searchQuery: "",
358 changeRequestByKey: new Map([
359 [
360 `${environmentId}:${thread.id}`,
361 {
362 state: "merged" as const,
363 linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
364 },
365 ],
366 ]),
367 now: NOW,297 now: NOW,
368 });298 });
369299
...@@ -371,23 +301,6 @@ describe("buildThreadListV2Items", () => {...@@ -371,23 +301,6 @@ describe("buildThreadListV2Items", () => {
371 expect(layout.items[0]?.variant).toBe("slim");301 expect(layout.items[0]?.variant).toBe("slim");
372 });302 });
373303
374 it("keeps a merged thread active when auto-settle on merge is off", () => {
375 const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });
376 const layout = buildThreadListV2Items({
377 threads: [merged],
378 environmentId: null,
379 searchQuery: "",
380 changeRequestByKey: new Map([
381 [`${environmentId}:${merged.id}`, { state: "merged" as const }],
382 ]),
383 autoSettleOnMerge: false,
384 now: NOW,
385 });
386
387 expect(layout.items.map((item) => item.thread.id)).toEqual(["merged"]);
388 expect(layout.settledCount).toBe(0);
389 });
390
391 it("hides snoozed threads and counts them — visibility parity with web", () => {304 it("hides snoozed threads and counts them — visibility parity with web", () => {
392 const layout = buildThreadListV2Items({305 const layout = buildThreadListV2Items({
393 threads: [306 threads: [
...@@ -439,73 +352,21 @@ describe("buildThreadListV2Items", () => {...@@ -439,73 +352,21 @@ describe("buildThreadListV2Items", () => {
439 expect(layout.settledCount).toBe(1);352 expect(layout.settledCount).toBe(1);
440 });353 });
441354
442 it("moves pinned threads to the settled shelf when their pull request merges", () => {355 it("keeps active pinned threads in the pinned block", () => {
443 const merged = makeThread({356 const pinned = makeThread({
444 id: ThreadId.make("pinned-merged"),357 id: ThreadId.make("pinned"),
445 title: "Pinned merged pull request",358 title: "Pinned thread",
446 pinnedAt: "2026-06-01T12:00:00.000Z",
447 });
448 const layout = buildThreadListV2Items({
449 threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged],
450 environmentId: null,
451 searchQuery: "",
452 changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]),
453 now: NOW,
454 });
455
456 expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-merged"]);
457 expect(layout.items.map((item) => item.variant)).toEqual(["card", "slim"]);
458 expect(layout.items[1]?.thread.pinnedAt).toBe("2026-06-01T12:00:00.000Z");
459 expect(layout.settledCount).toBe(1);
460 });
461
462 it("moves inactive pinned threads to the settled shelf", () => {
463 const inactive = makeThread({
464 id: ThreadId.make("pinned-inactive"),
465 title: "Pinned inactive thread",
466 createdAt: "2026-05-20T00:00:00.000Z",
467 pinnedAt: "2026-05-21T00:00:00.000Z",
468 latestTurn: {
469 turnId: TurnId.make("turn-inactive"),
470 state: "completed",
471 requestedAt: "2026-05-21T00:00:00.000Z",
472 startedAt: "2026-05-21T00:00:01.000Z",
473 completedAt: "2026-05-21T00:00:02.000Z",
474 assistantMessageId: null,
475 },
476 });
477 const layout = buildThreadListV2Items({
478 threads: [inactive],
479 environmentId: null,
480 searchQuery: "",
481 now: NOW,
482 });
483
484 expect(layout.items[0]).toMatchObject({
485 thread: { id: "pinned-inactive" },
486 variant: "slim",
487 pinned: false,
488 });
489 expect(layout.settledCount).toBe(1);
490 });
491
492 it("keeps pinned merged threads pinned when auto-settle on merge is off", () => {
493 const merged = makeThread({
494 id: ThreadId.make("pinned-merged"),
495 title: "Pinned merged pull request",
496 pinnedAt: "2026-06-01T12:00:00.000Z",359 pinnedAt: "2026-06-01T12:00:00.000Z",
497 });360 });
498 const layout = buildThreadListV2Items({361 const layout = buildThreadListV2Items({
499 threads: [merged],362 threads: [pinned],
500 environmentId: null,363 environmentId: null,
501 searchQuery: "",364 searchQuery: "",
502 changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]),
503 autoSettleOnMerge: false,
504 now: NOW,365 now: NOW,
505 });366 });
506367
507 expect(layout.items[0]).toMatchObject({368 expect(layout.items[0]).toMatchObject({
508 thread: { id: "pinned-merged" },369 thread: { id: "pinned" },
509 variant: "card",370 variant: "card",
510 pinned: true,371 pinned: true,
511 });372 });
...@@ -560,9 +421,7 @@ describe("buildThreadListV2Items", () => {...@@ -560,9 +421,7 @@ describe("buildThreadListV2Items", () => {
560 ],421 ],
561 environmentId: null,422 environmentId: null,
562 searchQuery: "",423 searchQuery: "",
563 // Minute-floored partition clock vs precise snooze clock.424 now: "2026-06-02T00:01:07.500Z",
564 now: "2026-06-02T00:01:00.000Z",
565 snoozeNow: "2026-06-02T00:01:07.500Z",
566 });425 });
567426
568 expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]);427 expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]);
apps/mobile/src/features/threads/threadListV2.ts+9-72
...@@ -1,22 +1,18 @@...@@ -1,22 +1,18 @@
1import {1import {
2 effectiveSettled,
3 effectiveSnoozed,2 effectiveSnoozed,
4 hasQueuedTurnStart,3 hasQueuedTurnStart,
5 QUEUED_TURN_START_GRACE_MS,4 QUEUED_TURN_START_GRACE_MS,
6 resolveSnoozePresets,5 resolveSnoozePresets,
7 snoozeWakeLabel,6 snoozeWakeLabel,
8} from "@t3tools/client-runtime/state/thread-settled";7} from "@t3tools/client-runtime/state/thread-settled";
9import type {8import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";
10 ChangeRequestSettleSource,
11 SnoozePreset,
12} from "@t3tools/client-runtime/state/thread-settled";
13import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";9import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
14import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";10import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
15import {11import {
16 activeThreadAnchorTimestampMs,12 activeThreadAnchorTimestampMs,
17 sortPinnedThreadsByOrderKey,13 sortPinnedThreadsByOrderKey,
18} from "@t3tools/client-runtime/state/thread-sort";14} from "@t3tools/client-runtime/state/thread-sort";
19import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts";15import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
2016
21import type { PendingNewTask } from "../../state/use-pending-new-tasks";17import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2218
...@@ -33,35 +29,6 @@ export { snoozeWakeLabel };...@@ -33,35 +29,6 @@ export { snoozeWakeLabel };
33export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";29export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";
34export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";30export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";
3531
36export interface ThreadListV2ChangeRequestState extends ChangeRequestSettleSource {
37 readonly linkedPullRequestKey?: string | null;
38}
39
40function linkedPullRequestKey(
41 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,
42): string | null {
43 if (linkedPullRequest == null) return null;
44 return JSON.stringify([
45 linkedPullRequest.projectId,
46 linkedPullRequest.repository.toLowerCase(),
47 linkedPullRequest.number,
48 ]);
49}
50
51/** Keep the previous linked PR state while its detail query reloads. */
52export function resolveThreadListV2ChangeRequestState(input: {
53 readonly linkedPullRequest: ThreadLinkedPullRequest | null | undefined;
54 readonly state: ChangeRequestSettleSource["state"] | null;
55 readonly updatedAt: string | null;
56}): ThreadListV2ChangeRequestState | null | undefined {
57 if (input.state === null) return input.linkedPullRequest == null ? null : undefined;
58 return {
59 state: input.state,
60 updatedAt: input.updatedAt,
61 linkedPullRequestKey: linkedPullRequestKey(input.linkedPullRequest),
62 };
63}
64
65export function resolveThreadListV2SnoozeMenuSelection(input: {32export function resolveThreadListV2SnoozeMenuSelection(input: {
66 readonly event: string;33 readonly event: string;
67 readonly displayedPresets: ReadonlyArray<SnoozePreset>;34 readonly displayedPresets: ReadonlyArray<SnoozePreset>;
...@@ -347,8 +314,7 @@ export function buildThreadListV2ListItems(input: {...@@ -347,8 +314,7 @@ export function buildThreadListV2ListItems(input: {
347314
348/**315/**
349 * Partitions visible threads into the active card block (creation order) and316 * Partitions visible threads into the active card block (creation order) and
350 * the settled recency tail, matching the web v2 list. Mobile stores these317 * the settled recency tail, matching the web v2 list.
351 * auto-settle preferences per device.
352 */318 */
353export function buildThreadListV2Items(input: {319export function buildThreadListV2Items(input: {
354 readonly threads: ReadonlyArray<EnvironmentThreadShell>;320 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
...@@ -359,8 +325,6 @@ export function buildThreadListV2Items(input: {...@@ -359,8 +325,6 @@ export function buildThreadListV2Items(input: {
359 }> | null;325 }> | null;
360 readonly searchQuery: string;326 readonly searchQuery: string;
361 readonly matchedThreadKeys?: ReadonlySet<string>;327 readonly matchedThreadKeys?: ReadonlySet<string>;
362 /** Per-row PR reported up by visible rows ("env:threadId" keys). */
363 readonly changeRequestByKey?: ReadonlyMap<string, ThreadListV2ChangeRequestState>;
364 /** Environments whose server supports thread.settle/unsettle. Threads on328 /** Environments whose server supports thread.settle/unsettle. Threads on
365 other environments never classify as settled — the user could neither329 other environments never classify as settled — the user could neither
366 un-settle nor pin them. Absent = no gating (tests). */330 un-settle nor pin them. Absent = no gating (tests). */
...@@ -368,17 +332,10 @@ export function buildThreadListV2Items(input: {...@@ -368,17 +332,10 @@ export function buildThreadListV2Items(input: {
368 /** Environments whose server supports thread.snooze/unsnooze. Same332 /** Environments whose server supports thread.snooze/unsnooze. Same
369 contract as settlementEnvironmentIds. */333 contract as settlementEnvironmentIds. */
370 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;334 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
371 readonly autoSettleAfterDays?: number;
372 readonly autoSettleOnMerge?: boolean;
373 /** Max settled rows to render; the rest are counted, not built. */335 /** Max settled rows to render; the rest are counted, not built. */
374 readonly settledLimit?: number;336 readonly settledLimit?: number;
375 /** Injectable for tests; defaults to now. */337 /** Second-precise clock used for time-based classification. */
376 readonly now?: string;338 readonly now: string;
377 /** Second-precise clock for snooze classification. Callers pass a
378 minute-quantized `now` for memoization; snooze wake times are
379 second-precise, so classifying with the floored minute would hold a
380 woken thread hidden for up to a minute. Defaults to `now`. */
381 readonly snoozeNow?: string;
382 /** Expands the snoozed shelf into rows. Collapsed is the default. */339 /** Expands the snoozed shelf into rows. Collapsed is the default. */
383 readonly snoozedShelfExpanded?: boolean;340 readonly snoozedShelfExpanded?: boolean;
384 /** Expands the settled shelf into rows. Expanded is the default. */341 /** Expands the settled shelf into rows. Expanded is the default. */
...@@ -387,10 +344,7 @@ export function buildThreadListV2Items(input: {...@@ -387,10 +344,7 @@ export function buildThreadListV2Items(input: {
387 a split-view detail can never lose its navigation row. */344 a split-view detail can never lose its navigation row. */
388 readonly selectedThreadKey?: string | null;345 readonly selectedThreadKey?: string | null;
389}): ThreadListV2Layout {346}): ThreadListV2Layout {
390 const now = input.now ?? new Date().toISOString();347 const now = input.now;
391 const snoozeNow = input.snoozeNow ?? now;
392 const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
393 const autoSettleOnMerge = input.autoSettleOnMerge ?? true;
394 const query = input.searchQuery.trim().toLocaleLowerCase();348 const query = input.searchQuery.trim().toLocaleLowerCase();
395 const projectKeys = input.projectRefs349 const projectKeys = input.projectRefs
396 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))350 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
...@@ -402,8 +356,7 @@ export function buildThreadListV2Items(input: {...@@ -402,8 +356,7 @@ export function buildThreadListV2Items(input: {
402 const snoozed: EnvironmentThreadShell[] = [];356 const snoozed: EnvironmentThreadShell[] = [];
403 let nextSnoozeWakeAt: string | null = null;357 let nextSnoozeWakeAt: string | null = null;
404 for (const thread of input.threads) {358 for (const thread of input.threads) {
405 // Callers pass live (unarchived) shells; settled threads are among them359 // Callers pass live shells. The server stamps settledOverride for the tail.
406 // and partition into the tail via effectiveSettled.
407 if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue;360 if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue;
408 if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) {361 if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) {
409 continue;362 continue;
...@@ -422,16 +375,8 @@ export function buildThreadListV2Items(input: {...@@ -422,16 +375,8 @@ export function buildThreadListV2Items(input: {
422 }375 }
423 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;376 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;
424 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;377 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;
425 const cachedChangeRequest =
426 input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;
427 const changeRequest =
428 cachedChangeRequest !== null &&
429 (cachedChangeRequest.linkedPullRequestKey ?? null) ===
430 linkedPullRequestKey(thread.linkedPullRequest)
431 ? cachedChangeRequest
432 : null;
433 // Snooze outranks settlement and pinning until the thread wakes.378 // Snooze outranks settlement and pinning until the thread wakes.
434 if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {379 if (supportsSnooze && effectiveSnoozed(thread, { now })) {
435 snoozed.push(thread);380 snoozed.push(thread);
436 if (381 if (
437 thread.snoozedUntil != null &&382 thread.snoozedUntil != null &&
...@@ -442,15 +387,7 @@ export function buildThreadListV2Items(input: {...@@ -442,15 +387,7 @@ export function buildThreadListV2Items(input: {
442 }387 }
443 continue;388 continue;
444 }389 }
445 if (390 if (supportsSettlement && thread.settledOverride === "settled") {
446 supportsSettlement &&
447 effectiveSettled(thread, {
448 now,
449 autoSettleAfterDays,
450 autoSettleOnMerge,
451 changeRequest,
452 })
453 ) {
454 settled.push(thread);391 settled.push(thread);
455 } else if (thread.pinnedAt != null) {392 } else if (thread.pinnedAt != null) {
456 pinned.push(thread);393 pinned.push(thread);
apps/mobile/src/persistence/mobile-preferences.ts-5
...@@ -31,7 +31,6 @@ export interface Preferences {...@@ -31,7 +31,6 @@ export interface Preferences {
31 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */31 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
32 readonly projectGroupingEnabled?: boolean;32 readonly projectGroupingEnabled?: boolean;
33 readonly projectGroupingMode?: SidebarProjectGroupingMode;33 readonly projectGroupingMode?: SidebarProjectGroupingMode;
34 readonly autoSettleOnMerge?: boolean;
35 /**34 /**
36 * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has35 * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has
37 * no client-settings sync, so the legacy grouped thread list is opted into36 * no client-settings sync, so the legacy grouped thread list is opted into
...@@ -101,7 +100,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -101,7 +100,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {
101 collapsedProjectGroups?: readonly string[];100 collapsedProjectGroups?: readonly string[];
102 projectGroupingEnabled?: boolean;101 projectGroupingEnabled?: boolean;
103 projectGroupingMode?: SidebarProjectGroupingMode;102 projectGroupingMode?: SidebarProjectGroupingMode;
104 autoSettleOnMerge?: boolean;
105 legacyThreadListEnabled?: boolean;103 legacyThreadListEnabled?: boolean;
106 planModeEnabled?: boolean;104 planModeEnabled?: boolean;
107 threadListV2SettledShelfExpanded?: boolean;105 threadListV2SettledShelfExpanded?: boolean;
...@@ -167,9 +165,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -167,9 +165,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {
167 ) {165 ) {
168 preferences.projectGroupingMode = parsed.projectGroupingMode;166 preferences.projectGroupingMode = parsed.projectGroupingMode;
169 }167 }
170 if (typeof parsed.autoSettleOnMerge === "boolean") {
171 preferences.autoSettleOnMerge = parsed.autoSettleOnMerge;
172 }
173 if (typeof parsed.legacyThreadListEnabled === "boolean") {168 if (typeof parsed.legacyThreadListEnabled === "boolean") {
174 preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;169 preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
175 }170 }
apps/server/integration/OrchestrationEngineHarness.integration.ts+7
...@@ -64,6 +64,7 @@ import {...@@ -64,6 +64,7 @@ import {
64 type OrchestrationEngineShape,64 type OrchestrationEngineShape,
65} from "../src/orchestration/Services/OrchestrationEngine.ts";65} from "../src/orchestration/Services/OrchestrationEngine.ts";
66import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts";66import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts";
67import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts";
67import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";68import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";
68import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";69import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";
69import {70import {
...@@ -376,6 +377,12 @@ export const makeOrchestrationIntegrationHarness = (...@@ -376,6 +377,12 @@ export const makeOrchestrationIntegrationHarness = (
376 drainThrough: () => Effect.void,377 drainThrough: () => Effect.void,
377 }),378 }),
378 ),379 ),
380 Layer.provideMerge(
381 Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, {
382 start: () => Effect.void,
383 drain: Effect.void,
384 }),
385 ),
379 Layer.provideMerge(386 Layer.provideMerge(
380 Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {387 Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {
381 publishThread: () => Effect.void,388 publishThread: () => Effect.void,
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -209,6 +209,7 @@ export const make = Effect.gen(function* () {...@@ -209,6 +209,7 @@ export const make = Effect.gen(function* () {
209 fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },209 fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },
210 pullRequests: true,210 pullRequests: true,
211 threadSettlement: true,211 threadSettlement: true,
212 threadAutoSettlement: true,
212 threadSnooze: true,213 threadSnooze: true,
213 environmentThemes: true,214 environmentThemes: true,
214 threadPinning: true,215 threadPinning: true,
apps/server/src/git/GitManager.test.ts+420-5
...@@ -620,6 +620,7 @@ function makeManager(input?: {...@@ -620,6 +620,7 @@ function makeManager(input?: {
620 textGeneration?: Partial<FakeGitTextGeneration>;620 textGeneration?: Partial<FakeGitTextGeneration>;
621 serverSettings?: Parameters<typeof ServerSettings.layerTest>[0];621 serverSettings?: Parameters<typeof ServerSettings.layerTest>[0];
622 setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];622 setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];
623 gitConfigReads?: string[];
623}) {624}) {
624 const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);625 const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);
625 const textGeneration = createTextGeneration(input?.textGeneration);626 const textGeneration = createTextGeneration(input?.textGeneration);
...@@ -629,11 +630,30 @@ function makeManager(input?: {...@@ -629,11 +630,30 @@ function makeManager(input?: {
629630
630 const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings);631 const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings);
631632
632 const vcsDriverLayer = GitVcsDriver.layer.pipe(633 const vcsDriverLayer = input?.gitConfigReads
633 Layer.provideMerge(VcsProcess.layer),634 ? Layer.effect(
634 Layer.provideMerge(NodeServices.layer),635 GitVcsDriver.GitVcsDriver,
635 Layer.provideMerge(serverConfigLayer),636 GitVcsDriver.make.pipe(
636 );637 Effect.map((service) =>
638 GitVcsDriver.GitVcsDriver.of({
639 ...service,
640 readConfigValue: (cwd, key) =>
641 Effect.sync(() => input.gitConfigReads?.push(key)).pipe(
642 Effect.andThen(service.readConfigValue(cwd, key)),
643 ),
644 }),
645 ),
646 ),
647 ).pipe(
648 Layer.provideMerge(VcsProcess.layer),
649 Layer.provideMerge(NodeServices.layer),
650 Layer.provideMerge(serverConfigLayer),
651 )
652 : GitVcsDriver.layer.pipe(
653 Layer.provideMerge(VcsProcess.layer),
654 Layer.provideMerge(NodeServices.layer),
655 Layer.provideMerge(serverConfigLayer),
656 );
637 const sourceControlRegistryLayer = Layer.effect(657 const sourceControlRegistryLayer = Layer.effect(
638 SourceControlProviderRegistry.SourceControlProviderRegistry,658 SourceControlProviderRegistry.SourceControlProviderRegistry,
639 GitHubSourceControlProvider.make.pipe(659 GitHubSourceControlProvider.make.pipe(
...@@ -955,6 +975,30 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -955,6 +975,30 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
955 }),975 }),
956 );976 );
957977
978 it.effect("a warm PR cache does not reread repository identity for status", () =>
979 Effect.gen(function* () {
980 const repoDir = yield* makeTempDir("t3code-git-manager-");
981 yield* initRepo(repoDir);
982 yield* runGit(repoDir, ["checkout", "-b", "feature/status-identity-cache"]);
983 const remoteDir = yield* createBareRemote();
984 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
985 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-identity-cache"]);
986
987 const gitConfigReads: string[] = [];
988 const { manager } = yield* makeManager({ gitConfigReads });
989
990 yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false });
991 gitConfigReads.length = 0;
992 yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false });
993
994 const identityReads = gitConfigReads.filter(
995 (key) =>
996 key === "branch.feature/status-identity-cache.remote" || key === "remote.origin.url",
997 );
998 expect(identityReads).toHaveLength(0);
999 }),
1000 );
1001
958 it.effect("status skips the provider lookup for a branch that was never pushed", () =>1002 it.effect("status skips the provider lookup for a branch that was never pushed", () =>
959 Effect.gen(function* () {1003 Effect.gen(function* () {
960 const repoDir = yield* makeTempDir("t3code-git-manager-");1004 const repoDir = yield* makeTempDir("t3code-git-manager-");
...@@ -974,6 +1018,377 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -974,6 +1018,377 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
974 }),1018 }),
975 );1019 );
9761020
1021 it.effect("branch PR lookup returns null when the repository has no remotes", () =>
1022 Effect.gen(function* () {
1023 const repoDir = yield* makeTempDir("t3code-git-manager-");
1024 yield* initRepo(repoDir);
1025 const { manager, ghCalls } = yield* makeManager();
1026
1027 const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" });
1028
1029 expect(pullRequest).toBeNull();
1030 expect(ghCalls).toHaveLength(0);
1031 }),
1032 );
1033
1034 it.effect("branch PR lookup uses a saved tracked branch without changing checkout", () =>
1035 Effect.gen(function* () {
1036 const repoDir = yield* makeTempDir("t3code-git-manager-");
1037 yield* initRepo(repoDir);
1038 const remoteDir = yield* createBareRemote();
1039 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1040 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1041 yield* runGit(repoDir, ["checkout", "-b", "feature/saved-branch"]);
1042 yield* runGit(repoDir, ["push", "-u", "origin", "feature/saved-branch"]);
1043 yield* runGit(repoDir, ["checkout", "main"]);
1044
1045 const { manager } = yield* makeManager({
1046 ghScenario: {
1047 prListSequence: [
1048 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
1049 // @effect-diagnostics-next-line preferSchemaOverJson:off
1050 JSON.stringify([
1051 {
1052 number: 216,
1053 title: "Saved branch PR",
1054 url: "https://github.com/pingdotgg/t3code/pull/216",
1055 baseRefName: "main",
1056 headRefName: "feature/saved-branch",
1057 state: "OPEN",
1058 updatedAt: "2026-04-03T15:00:00Z",
1059 },
1060 ]),
1061 ],
1062 },
1063 });
1064
1065 const pullRequest = yield* manager.branchPullRequest({
1066 cwd: repoDir,
1067 branch: "feature/saved-branch",
1068 });
1069
1070 expect(pullRequest).toEqual({
1071 state: "open",
1072 updatedAt: "2026-04-03T15:00:00.000Z",
1073 });
1074 expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main");
1075 }),
1076 );
1077
1078 it.effect("branch PR lookup uses the default branch from a non-origin remote", () =>
1079 Effect.gen(function* () {
1080 const repoDir = yield* makeTempDir("t3code-git-manager-");
1081 yield* initRepo(repoDir);
1082 const remoteDir = yield* createBareRemote();
1083 yield* runGit(repoDir, ["remote", "add", "upstream", remoteDir]);
1084 yield* runGit(repoDir, ["push", "-u", "upstream", "main"]);
1085 yield* runGit(repoDir, ["checkout", "-b", "develop"]);
1086 yield* runGit(repoDir, ["push", "-u", "upstream", "develop"]);
1087 yield* runGit(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/develop"]);
1088 yield* runGit(repoDir, ["remote", "set-head", "upstream", "develop"]);
1089
1090 const { manager } = yield* makeManager({
1091 ghScenario: {
1092 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
1093 prListSequence: [
1094 // @effect-diagnostics-next-line preferSchemaOverJson:off
1095 JSON.stringify([
1096 {
1097 number: 221,
1098 title: "Merged main PR",
1099 url: "https://github.com/pingdotgg/codething-mvp/pull/221",
1100 baseRefName: "develop",
1101 headRefName: "main",
1102 state: "MERGED",
1103 updatedAt: "2026-04-08T15:00:00Z",
1104 },
1105 ]),
1106 ],
1107 },
1108 });
1109
1110 const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" });
1111
1112 expect(pullRequest).toEqual({
1113 state: "merged",
1114 updatedAt: "2026-04-08T15:00:00.000Z",
1115 });
1116 }),
1117 );
1118
1119 it.effect("branch PR lookup uses the saved name after the local branch is deleted", () =>
1120 Effect.gen(function* () {
1121 const repoDir = yield* makeTempDir("t3code-git-manager-");
1122 yield* initRepo(repoDir);
1123 const remoteDir = yield* createBareRemote();
1124 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1125 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1126 yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-local-branch"]);
1127 yield* runGit(repoDir, ["push", "-u", "origin", "feature/deleted-local-branch"]);
1128 yield* runGit(repoDir, ["checkout", "main"]);
1129 yield* runGit(repoDir, ["branch", "-D", "feature/deleted-local-branch"]);
1130 yield* runGit(repoDir, ["branch", "feature/deleted-local-branch/child"]);
1131 yield* runGit(repoDir, [
1132 "branch",
1133 "--set-upstream-to",
1134 "origin/main",
1135 "feature/deleted-local-branch/child",
1136 ]);
1137
1138 const { manager, ghCalls } = yield* makeManager({
1139 ghScenario: {
1140 prListSequence: [
1141 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
1142 // @effect-diagnostics-next-line preferSchemaOverJson:off
1143 JSON.stringify([
1144 {
1145 number: 217,
1146 title: "Deleted local branch PR",
1147 url: "https://github.com/pingdotgg/t3code/pull/217",
1148 baseRefName: "main",
1149 headRefName: "feature/deleted-local-branch",
1150 state: "MERGED",
1151 updatedAt: "2026-04-04T15:00:00Z",
1152 },
1153 ]),
1154 ],
1155 },
1156 });
1157
1158 const pullRequest = yield* manager.branchPullRequest({
1159 cwd: repoDir,
1160 branch: "feature/deleted-local-branch",
1161 });
1162
1163 expect(pullRequest).toEqual({
1164 state: "merged",
1165 updatedAt: "2026-04-04T15:00:00.000Z",
1166 });
1167 expect(ghCalls.some((call) => call.includes("--head feature/deleted-local-branch"))).toBe(
1168 true,
1169 );
1170 }),
1171 );
1172
1173 it.effect("branch PR lookup recovers a deleted fork branch from its remote-tracking ref", () =>
1174 Effect.gen(function* () {
1175 const repoDir = yield* makeTempDir("t3code-git-manager-");
1176 yield* initRepo(repoDir);
1177 const originDir = yield* createBareRemote();
1178 const forkDir = yield* createBareRemote();
1179 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1180 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1181 yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork");
1182 yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-fork-branch"]);
1183 yield* runGit(repoDir, ["push", "-u", "team/fork", "feature/deleted-fork-branch"]);
1184 yield* runGit(repoDir, ["checkout", "main"]);
1185 yield* runGit(repoDir, ["branch", "-D", "feature/deleted-fork-branch"]);
1186 yield* configureVisibleRemoteUrlWithLocalRewrite(
1187 repoDir,
1188 "origin",
1189 "git@github.com:pingdotgg/codething-mvp.git",
1190 originDir,
1191 );
1192 yield* configureVisibleRemoteUrlWithLocalRewrite(
1193 repoDir,
1194 "team/fork",
1195 "git@github.com:contributor/codething-mvp.git",
1196 forkDir,
1197 );
1198
1199 const { manager, ghCalls } = yield* makeManager({
1200 ghScenario: {
1201 prListByHeadSelector: {
1202 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
1203 // @effect-diagnostics-next-line preferSchemaOverJson:off
1204 "contributor:feature/deleted-fork-branch": JSON.stringify([
1205 {
1206 number: 218,
1207 title: "Deleted fork branch PR",
1208 url: "https://github.com/pingdotgg/codething-mvp/pull/218",
1209 baseRefName: "main",
1210 headRefName: "feature/deleted-fork-branch",
1211 state: "MERGED",
1212 updatedAt: "2026-04-05T15:00:00Z",
1213 isCrossRepository: true,
1214 headRepository: { nameWithOwner: "contributor/codething-mvp" },
1215 headRepositoryOwner: { login: "contributor" },
1216 },
1217 ]),
1218 },
1219 },
1220 });
1221
1222 const pullRequest = yield* manager.branchPullRequest({
1223 cwd: repoDir,
1224 branch: "feature/deleted-fork-branch",
1225 });
1226
1227 expect(pullRequest).toEqual({
1228 state: "merged",
1229 updatedAt: "2026-04-05T15:00:00.000Z",
1230 });
1231 expect(
1232 ghCalls.some((call) => call.includes("--head contributor:feature/deleted-fork-branch")),
1233 ).toBe(true);
1234 }),
1235 );
1236
1237 it.effect("branch PR lookup rejects ambiguous deleted-branch remote refs", () =>
1238 Effect.gen(function* () {
1239 const repoDir = yield* makeTempDir("t3code-git-manager-");
1240 yield* initRepo(repoDir);
1241 const originDir = yield* createBareRemote();
1242 const forkDir = yield* createBareRemote();
1243 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1244 yield* runGit(repoDir, ["remote", "add", "fork", forkDir]);
1245 yield* runGit(repoDir, ["checkout", "-b", "feature/ambiguous-remote"]);
1246 yield* runGit(repoDir, ["push", "origin", "feature/ambiguous-remote"]);
1247 yield* runGit(repoDir, ["push", "fork", "feature/ambiguous-remote"]);
1248 yield* runGit(repoDir, ["checkout", "main"]);
1249 yield* runGit(repoDir, ["branch", "-D", "feature/ambiguous-remote"]);
1250 const { manager, ghCalls } = yield* makeManager();
1251
1252 const error = yield* manager
1253 .branchPullRequest({ cwd: repoDir, branch: "feature/ambiguous-remote" })
1254 .pipe(Effect.flip);
1255
1256 expect(error).toMatchObject({
1257 _tag: "GitManagerError",
1258 detail: "Multiple remotes track feature/ambiguous-remote. Its pull request is ambiguous.",
1259 });
1260 expect(ghCalls).toHaveLength(0);
1261 }),
1262 );
1263
1264 it.effect("branch PR lookup does not reuse a cached PR after the remote is repointed", () =>
1265 Effect.gen(function* () {
1266 const repoDir = yield* makeTempDir("t3code-git-manager-");
1267 yield* initRepo(repoDir);
1268 const originalRemoteDir = yield* createBareRemote();
1269 yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]);
1270 yield* runGit(repoDir, ["checkout", "-b", "feature/repointed-lookup"]);
1271 yield* runGit(repoDir, ["push", "-u", "origin", "feature/repointed-lookup"]);
1272 yield* configureVisibleRemoteUrlWithLocalRewrite(
1273 repoDir,
1274 "origin",
1275 "git@github.com:old-owner/old-repository.git",
1276 originalRemoteDir,
1277 );
1278 const { manager, ghCalls } = yield* makeManager({
1279 ghScenario: {
1280 prListSequence: [
1281 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
1282 // @effect-diagnostics-next-line preferSchemaOverJson:off
1283 JSON.stringify([
1284 {
1285 number: 219,
1286 title: "Old repository PR",
1287 url: "https://github.com/old-owner/old-repository/pull/219",
1288 baseRefName: "main",
1289 headRefName: "feature/repointed-lookup",
1290 state: "MERGED",
1291 updatedAt: "2026-04-06T15:00:00Z",
1292 },
1293 ]),
1294 "[]",
1295 ],
1296 },
1297 });
1298
1299 const first = yield* manager.branchPullRequest({
1300 cwd: repoDir,
1301 branch: "feature/repointed-lookup",
1302 });
1303 expect(first?.state).toBe("merged");
1304
1305 const replacementRemoteDir = yield* createBareRemote();
1306 yield* configureVisibleRemoteUrlWithLocalRewrite(
1307 repoDir,
1308 "origin",
1309 "git@github.com:new-owner/new-repository.git",
1310 replacementRemoteDir,
1311 );
1312
1313 const second = yield* manager.branchPullRequest({
1314 cwd: repoDir,
1315 branch: "feature/repointed-lookup",
1316 });
1317
1318 expect(second).toBeNull();
1319 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);
1320 }),
1321 );
1322
1323 it.effect("branch PR lookup shares the status cache for the same repository identity", () =>
1324 Effect.gen(function* () {
1325 const repoDir = yield* makeTempDir("t3code-git-manager-");
1326 yield* initRepo(repoDir);
1327 const remoteDir = yield* createBareRemote();
1328 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1329 yield* runGit(repoDir, ["checkout", "-b", "feature/shared-pr-cache"]);
1330 yield* runGit(repoDir, ["push", "-u", "origin", "feature/shared-pr-cache"]);
1331 const { manager, ghCalls } = yield* makeManager({
1332 ghScenario: {
1333 prListSequence: [
1334 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
1335 // @effect-diagnostics-next-line preferSchemaOverJson:off
1336 JSON.stringify([
1337 {
1338 number: 220,
1339 title: "Shared cache PR",
1340 url: "https://github.com/pingdotgg/codething-mvp/pull/220",
1341 baseRefName: "main",
1342 headRefName: "feature/shared-pr-cache",
1343 state: "MERGED",
1344 updatedAt: "2026-04-07T15:00:00Z",
1345 },
1346 ]),
1347 ],
1348 },
1349 });
1350
1351 const status = yield* manager.status({ cwd: repoDir });
1352 const pullRequest = yield* manager.branchPullRequest({
1353 cwd: repoDir,
1354 branch: "feature/shared-pr-cache",
1355 });
1356
1357 expect(status.pr?.state).toBe("merged");
1358 expect(pullRequest?.state).toBe("merged");
1359 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);
1360 }),
1361 );
1362
1363 it.effect("branch PR lookup propagates provider failures", () =>
1364 Effect.gen(function* () {
1365 const repoDir = yield* makeTempDir("t3code-git-manager-");
1366 yield* initRepo(repoDir);
1367 const remoteDir = yield* createBareRemote();
1368 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1369 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1370 yield* runGit(repoDir, ["checkout", "-b", "feature/lookup-failure"]);
1371 yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-failure"]);
1372 yield* runGit(repoDir, ["checkout", "main"]);
1373
1374 const { manager } = yield* makeManager({
1375 ghScenario: {
1376 failWith: new GitHubCli.GitHubCliUnavailableError({
1377 command: "gh",
1378 cwd: repoDir,
1379 cause: new Error("gh is not available on PATH"),
1380 }),
1381 },
1382 });
1383
1384 const error = yield* manager
1385 .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" })
1386 .pipe(Effect.flip);
1387
1388 expect(error._tag).toBe("SourceControlProviderError");
1389 }),
1390 );
1391
977 it.effect("status finds a merged PR after its remote branch was deleted", () =>1392 it.effect("status finds a merged PR after its remote branch was deleted", () =>
978 Effect.gen(function* () {1393 Effect.gen(function* () {
979 const repoDir = yield* makeTempDir("t3code-git-manager-");1394 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/GitManager.ts+190-7
...@@ -90,6 +90,14 @@ export class GitManager extends Context.Service<...@@ -90,6 +90,14 @@ export class GitManager extends Context.Service<
90 input: VcsStatusInput,90 input: VcsStatusInput,
91 options?: GitVcsDriver.GitRemoteStatusOptions,91 options?: GitVcsDriver.GitRemoteStatusOptions,
92 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;92 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;
93 /** Resolve the PR for a saved branch without changing the current checkout. */
94 readonly branchPullRequest: (input: {
95 readonly cwd: string;
96 readonly branch: string;
97 }) => Effect.Effect<
98 { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null } | null,
99 GitManagerServiceError
100 >;
93 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;101 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
94 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;102 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
95 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;103 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
...@@ -181,6 +189,7 @@ interface BranchHeadContext {...@@ -181,6 +189,7 @@ interface BranchHeadContext {
181 preferredHeadSelector: string;189 preferredHeadSelector: string;
182 remoteName: string | null;190 remoteName: string | null;
183 headRemoteUrlKey: string | null;191 headRemoteUrlKey: string | null;
192 targetRemoteUrlKey: string | null;
184 headRepositoryNameWithOwner: string | null;193 headRepositoryNameWithOwner: string | null;
185 headRepositoryOwnerLogin: string | null;194 headRepositoryOwnerLogin: string | null;
186 isCrossRepository: boolean;195 isCrossRepository: boolean;
...@@ -938,15 +947,16 @@ export const make = Effect.gen(function* () {...@@ -938,15 +947,16 @@ export const make = Effect.gen(function* () {
938 prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1);947 prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1);
939 }),948 }),
940 );949 );
941 // Cache keys are NUL-joined [cwd, branch, upstreamRef, defaultBranch, epoch] — none of the950 // Cache keys are NUL-joined. Automatic settlement validates repository URLs
942 // segments can contain a NUL byte, and refs are never empty, so "" decodes951 // against the cached value before it uses a pull request decision.
943 // back to a null ref.
944 const prLookupCacheKey = (952 const prLookupCacheKey = (
945 cwd: string,953 cwd: string,
946 details: {954 details: {
947 branch: string;955 branch: string;
948 upstreamRef: string | null;956 upstreamRef: string | null;
949 defaultBranch: string | null;957 defaultBranch: string | null;
958 localBranchExists?: boolean;
959 remoteName?: string | null;
950 },960 },
951 ) =>961 ) =>
952 [962 [
...@@ -954,6 +964,8 @@ export const make = Effect.gen(function* () {...@@ -954,6 +964,8 @@ export const make = Effect.gen(function* () {
954 details.branch,964 details.branch,
955 details.upstreamRef ?? "",965 details.upstreamRef ?? "",
956 details.defaultBranch ?? "",966 details.defaultBranch ?? "",
967 details.localBranchExists === false ? "0" : "1",
968 details.remoteName ?? "",
957 String(prLookupEpoch(cwd)),969 String(prLookupEpoch(cwd)),
958 ].join("\u0000");970 ].join("\u0000");
959 // Consecutive failures per cache key, so a branch that keeps failing waits971 // Consecutive failures per cache key, so a branch that keeps failing waits
...@@ -975,11 +987,20 @@ export const make = Effect.gen(function* () {...@@ -975,11 +987,20 @@ export const make = Effect.gen(function* () {
975 };987 };
976 const prLookupCache = yield* Cache.makeWith(988 const prLookupCache = yield* Cache.makeWith(
977 (key: string) => {989 (key: string) => {
978 const [cwd = "", branch = "", upstreamRef = "", defaultBranch = ""] = key.split("\u0000");990 const [
991 cwd = "",
992 branch = "",
993 upstreamRef = "",
994 defaultBranch = "",
995 branchExists = "1",
996 remoteName = "",
997 ] = key.split("\u0000");
979 const details = {998 const details = {
980 branch,999 branch,
981 upstreamRef: upstreamRef.length > 0 ? upstreamRef : null,1000 upstreamRef: upstreamRef.length > 0 ? upstreamRef : null,
982 defaultBranch: defaultBranch.length > 0 ? defaultBranch : null,1001 defaultBranch: defaultBranch.length > 0 ? defaultBranch : null,
1002 localBranchExists: branchExists !== "0",
1003 ...(remoteName.length > 0 ? { remoteName } : {}),
983 };1004 };
984 return Effect.gen(function* () {1005 return Effect.gen(function* () {
985 const headContext = yield* resolveBranchHeadContext(cwd, details);1006 const headContext = yield* resolveBranchHeadContext(cwd, details);
...@@ -1000,7 +1021,11 @@ export const make = Effect.gen(function* () {...@@ -1000,7 +1021,11 @@ export const make = Effect.gen(function* () {
1000 }1021 }
1001 // Only skip when the branch is untracked as well: anything carrying an1022 // Only skip when the branch is untracked as well: anything carrying an
1002 // upstream keeps the old behaviour.1023 // upstream keeps the old behaviour.
1003 if (details.upstreamRef === null && (yield* isUnpublishedBranch(cwd, headContext))) {1024 if (
1025 details.localBranchExists &&
1026 details.upstreamRef === null &&
1027 (yield* isUnpublishedBranch(cwd, headContext))
1028 ) {
1004 return { latest: null, headContext };1029 return { latest: null, headContext };
1005 }1030 }
1006 const latest = yield* findLatestPrForHeadContext(cwd, headContext);1031 const latest = yield* findLatestPrForHeadContext(cwd, headContext);
...@@ -1218,11 +1243,33 @@ export const make = Effect.gen(function* () {...@@ -1218,11 +1243,33 @@ export const make = Effect.gen(function* () {
1218 };1243 };
1219 });1244 });
12201245
1246 const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")(
1247 function* (cwd: string, branch: string, remoteNameOverride?: string) {
1248 const remoteName =
1249 remoteNameOverride ?? (yield* readConfigValueNullable(cwd, `branch.${branch}.remote`));
1250 const [headRemote, targetRemote] = yield* Effect.all(
1251 [
1252 resolveRemoteRepositoryContext(cwd, remoteName),
1253 resolveRemoteRepositoryContext(cwd, "origin"),
1254 ],
1255 { concurrency: "unbounded" },
1256 );
1257 return {
1258 remoteName,
1259 headRemoteUrlKey:
1260 headRemote.remoteUrlKey ?? (remoteName === null ? targetRemote.remoteUrlKey : null),
1261 targetRemoteUrlKey: targetRemote.remoteUrlKey,
1262 };
1263 },
1264 );
1265
1221 const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* (1266 const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* (
1222 cwd: string,1267 cwd: string,
1223 details: { branch: string; upstreamRef: string | null },1268 details: { branch: string; upstreamRef: string | null; remoteName?: string },
1224 ) {1269 ) {
1225 const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`);1270 const remoteName =
1271 details.remoteName ??
1272 (yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`));
1226 const headBranchFromUpstream = details.upstreamRef1273 const headBranchFromUpstream = details.upstreamRef
1227 ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName })1274 ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName })
1228 : "";1275 : "";
...@@ -1286,6 +1333,7 @@ export const make = Effect.gen(function* () {...@@ -1286,6 +1333,7 @@ export const make = Effect.gen(function* () {
1286 headRemoteUrlKey:1333 headRemoteUrlKey:
1287 remoteRepository.remoteUrlKey ??1334 remoteRepository.remoteUrlKey ??
1288 (remoteName === null ? originRepository.remoteUrlKey : null),1335 (remoteName === null ? originRepository.remoteUrlKey : null),
1336 targetRemoteUrlKey: originRepository.remoteUrlKey,
1289 headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner,1337 headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner,
1290 headRepositoryOwnerLogin: remoteRepository.ownerLogin,1338 headRepositoryOwnerLogin: remoteRepository.ownerLogin,
1291 isCrossRepository,1339 isCrossRepository,
...@@ -1869,6 +1917,140 @@ export const make = Effect.gen(function* () {...@@ -1869,6 +1917,140 @@ export const make = Effect.gen(function* () {
1869 });1917 });
1870 return mergeGitStatusParts(local, remote);1918 return mergeGitStatusParts(local, remote);
1871 });1919 });
1920 const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn(
1921 "branchPullRequest",
1922 )(function* ({ cwd, branch }) {
1923 const cacheCwd = yield* normalizeStatusCacheKey(cwd);
1924 const remotes = yield* gitCore.execute({
1925 operation: "GitManager.branchPullRequest.remotes",
1926 cwd: cacheCwd,
1927 args: ["remote"],
1928 });
1929 const remoteNames = remotes.stdout
1930 .split("\n")
1931 .map((remoteName) => remoteName.trim())
1932 .filter((remoteName) => remoteName.length > 0);
1933 const [firstRemoteName] = remoteNames;
1934 if (firstRemoteName === undefined) return null;
1935 const branchRef = yield* gitCore.execute({
1936 operation: "GitManager.branchPullRequest.branchRef",
1937 cwd: cacheCwd,
1938 args: [
1939 "for-each-ref",
1940 "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)",
1941 `refs/heads/${branch}`,
1942 ],
1943 });
1944 const expectedRefName = `refs/heads/${branch}`;
1945 const exactBranch = branchRef.stdout
1946 .split("\n")
1947 .find((line) => line.split("\u0000", 1)[0] === expectedRefName);
1948 const [refName = "", savedUpstream = "", savedRemoteName = "", savedRemoteRef = ""] =
1949 exactBranch?.split("\u0000") ?? [];
1950 const localBranchExists = refName.length > 0;
1951 let upstreamRef: string | null = null;
1952 let remoteName: string | null = null;
1953 if (savedUpstream.length > 0) {
1954 if (savedRemoteName.length === 0 || savedRemoteRef.length === 0) {
1955 return yield* new GitManagerError({
1956 operation: "branchPullRequest",
1957 cwd: cacheCwd,
1958 detail: `Saved upstream for ${branch} is incomplete.`,
1959 });
1960 }
1961 remoteName = savedRemoteName;
1962 const upstreamBranch = savedRemoteRef.replace(/^refs\/heads\//, "");
1963 upstreamRef = `${remoteName}/${upstreamBranch}`;
1964 } else if (!localBranchExists) {
1965 const trackingRefs = yield* gitCore.execute({
1966 operation: "GitManager.branchPullRequest.remoteTrackingRefs",
1967 cwd: cacheCwd,
1968 args: ["for-each-ref", "--format=%(refname)", "refs/remotes"],
1969 });
1970 const refNames = new Set(
1971 trackingRefs.stdout
1972 .split("\n")
1973 .map((remoteRef) => remoteRef.trim())
1974 .filter((remoteRef) => remoteRef.length > 0),
1975 );
1976 const matchingRemoteNames = remoteNames.filter((candidate) =>
1977 refNames.has(`refs/remotes/${candidate}/${branch}`),
1978 );
1979 if (matchingRemoteNames.length > 1) {
1980 return yield* new GitManagerError({
1981 operation: "branchPullRequest",
1982 cwd: cacheCwd,
1983 detail: `Multiple remotes track ${branch}. Its pull request is ambiguous.`,
1984 });
1985 }
1986 remoteName = matchingRemoteNames[0] ?? null;
1987 if (remoteName !== null) {
1988 upstreamRef = `${remoteName}/${branch}`;
1989 }
1990 }
1991 const defaultRemoteName = remoteNames.includes("origin") ? "origin" : firstRemoteName;
1992 const defaultBranch = yield* gitCore
1993 .resolveDefaultBranchName(cacheCwd, defaultRemoteName)
1994 .pipe(Effect.orElseSucceed(() => null));
1995 const cacheKey = prLookupCacheKey(cacheCwd, {
1996 branch,
1997 upstreamRef,
1998 defaultBranch,
1999 localBranchExists,
2000 ...(localBranchExists ? {} : { remoteName }),
2001 });
2002 let cached = yield* Cache.get(prLookupCache, cacheKey);
2003 const currentIdentity = yield* resolvePrLookupRepositoryIdentity(
2004 cacheCwd,
2005 branch,
2006 remoteName ?? undefined,
2007 );
2008 const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) =>
2009 !(
2010 (headContext.headRemoteUrlKey !== null && identity.headRemoteUrlKey === null) ||
2011 (headContext.targetRemoteUrlKey !== null && identity.targetRemoteUrlKey === null)
2012 );
2013 const hasSameIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) =>
2014 headContext.headRemoteUrlKey === identity.headRemoteUrlKey &&
2015 headContext.targetRemoteUrlKey === identity.targetRemoteUrlKey;
2016 if (!canVerifyIdentity(cached.headContext, currentIdentity)) {
2017 return yield* new GitManagerError({
2018 operation: "branchPullRequest",
2019 cwd: cacheCwd,
2020 detail: `Repository identity for ${branch} could not be verified.`,
2021 });
2022 }
2023 if (!hasSameIdentity(cached.headContext, currentIdentity)) {
2024 yield* Cache.invalidate(prLookupCache, cacheKey);
2025 cached = yield* Cache.get(prLookupCache, cacheKey);
2026 const refreshedIdentity = yield* resolvePrLookupRepositoryIdentity(
2027 cacheCwd,
2028 branch,
2029 remoteName ?? undefined,
2030 );
2031 if (
2032 !canVerifyIdentity(cached.headContext, refreshedIdentity) ||
2033 !hasSameIdentity(cached.headContext, refreshedIdentity)
2034 ) {
2035 return yield* new GitManagerError({
2036 operation: "branchPullRequest",
2037 cwd: cacheCwd,
2038 detail: `Repository identity for ${branch} changed during pull request lookup.`,
2039 });
2040 }
2041 }
2042 const { latest } = cached;
2043 if (latest === null) return null;
2044 if (
2045 (branch === defaultBranch ||
2046 (defaultBranch === null && (branch === "main" || branch === "master"))) &&
2047 latest.state !== "open"
2048 ) {
2049 return null;
2050 }
2051 const statusPr = toStatusPr(latest);
2052 return { state: statusPr.state, updatedAt: statusPr.updatedAt };
2053 });
1872 const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn(2054 const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn(
1873 "invalidateLocalStatus",2055 "invalidateLocalStatus",
1874 )(function* (cwd) {2056 )(function* (cwd) {
...@@ -2416,6 +2598,7 @@ export const make = Effect.gen(function* () {...@@ -2416,6 +2598,7 @@ export const make = Effect.gen(function* () {
2416 localStatus,2598 localStatus,
2417 remoteStatus,2599 remoteStatus,
2418 status,2600 status,
2601 branchPullRequest,
2419 invalidateLocalStatus,2602 invalidateLocalStatus,
2420 invalidateRemoteStatus,2603 invalidateRemoteStatus,
2421 invalidateStatus,2604 invalidateStatus,
apps/server/src/orchestration/Errors.ts+20-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { ThreadId } from "@t3tools/contracts";
1import * as SchemaIssue from "effect/SchemaIssue";2import * as SchemaIssue from "effect/SchemaIssue";
2import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
34
...@@ -40,6 +41,24 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass<...@@ -40,6 +41,24 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass<
40 }41 }
41}42}
4243
44export class OrchestrationThreadSettleBlockedError extends Schema.TaggedErrorClass<OrchestrationThreadSettleBlockedError>()(
45 "OrchestrationThreadSettleBlockedError",
46 {
47 threadId: ThreadId,
48 },
49) {
50 override get message(): string {
51 return "This thread still needs attention. Resolve or interrupt it first, then try again.";
52 }
53}
54
55export const OrchestrationCommandRejection = Schema.Union([
56 OrchestrationCommandInvariantError,
57 OrchestrationThreadSettleBlockedError,
58]);
59export type OrchestrationCommandRejection = typeof OrchestrationCommandRejection.Type;
60export const isOrchestrationCommandRejection = Schema.is(OrchestrationCommandRejection);
61
43export class OrchestrationCommandPreviouslyRejectedError extends Schema.TaggedErrorClass<OrchestrationCommandPreviouslyRejectedError>()(62export class OrchestrationCommandPreviouslyRejectedError extends Schema.TaggedErrorClass<OrchestrationCommandPreviouslyRejectedError>()(
44 "OrchestrationCommandPreviouslyRejectedError",63 "OrchestrationCommandPreviouslyRejectedError",
45 {64 {
...@@ -96,7 +115,7 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass<...@@ -96,7 +115,7 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass<
96115
97export type OrchestrationDispatchError =116export type OrchestrationDispatchError =
98 | ProjectionRepositoryError117 | ProjectionRepositoryError
99 | OrchestrationCommandInvariantError118 | OrchestrationCommandRejection
100 | OrchestrationCommandIdConflictError119 | OrchestrationCommandIdConflictError
101 | OrchestrationCommandPreviouslyRejectedError120 | OrchestrationCommandPreviouslyRejectedError
102 | OrchestrationProjectorDecodeError121 | OrchestrationProjectorDecodeError
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+211-5
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 ProviderInstanceId,10 ProviderInstanceId,
11} from "@t3tools/contracts";11} from "@t3tools/contracts";
12import * as NodeServices from "@effect/platform-node/NodeServices";12import * as NodeServices from "@effect/platform-node/NodeServices";
13import { it as effectIt } from "@effect/vitest";
13import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
15import * as ManagedRuntime from "effect/ManagedRuntime";16import * as ManagedRuntime from "effect/ManagedRuntime";
...@@ -17,10 +18,12 @@ import * as Metric from "effect/Metric";...@@ -17,10 +18,12 @@ import * as Metric from "effect/Metric";
17import * as Option from "effect/Option";18import * as Option from "effect/Option";
18import * as Queue from "effect/Queue";19import * as Queue from "effect/Queue";
19import * as Stream from "effect/Stream";20import * as Stream from "effect/Stream";
21import { TestClock } from "effect/testing";
20import { describe, expect, it } from "vite-plus/test";22import { describe, expect, it } from "vite-plus/test";
2123
22import { PersistenceSqlError } from "../../persistence/Errors.ts";24import { PersistenceSqlError } from "../../persistence/Errors.ts";
23import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";25import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
26import * as OrchestrationCommandReceipts from "../../persistence/Services/OrchestrationCommandReceipts.ts";
24import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";27import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
25import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";28import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
26import {29import {
...@@ -46,27 +49,30 @@ const asMessageId = (value: string): MessageId => MessageId.make(value);...@@ -46,27 +49,30 @@ const asMessageId = (value: string): MessageId => MessageId.make(value);
46const asTurnId = (value: string): TurnId => TurnId.make(value);49const asTurnId = (value: string): TurnId => TurnId.make(value);
47const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value);50const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value);
4851
49async function createOrchestrationSystem() {52function makeOrchestrationLayer() {
50 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {53 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
51 prefix: "t3-orchestration-engine-test-",54 prefix: "t3-orchestration-engine-test-",
52 });55 });
53 const orchestrationLayer = Layer.mergeAll(56 return Layer.mergeAll(
54 OrchestrationEngineLive.pipe(57 OrchestrationEngineLive.pipe(
55 Layer.provide(OrchestrationProjectionSnapshotQueryLive),58 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
56 Layer.provide(OrchestrationProjectionPipelineLive),59 Layer.provide(OrchestrationProjectionPipelineLive),
57 ),60 ),
58 OrchestrationProjectionSnapshotQueryLive,61 OrchestrationProjectionSnapshotQueryLive,
59 ).pipe(62 ).pipe(
60 Layer.provide(ThreadBackgroundLiveness.layer),63 Layer.provideMerge(ThreadBackgroundLiveness.layer),
61 Layer.provide(ThreadPlanProgress.layer),64 Layer.provide(ThreadPlanProgress.layer),
62 Layer.provide(OrchestrationEventStoreLive),65 Layer.provide(OrchestrationEventStoreLive),
63 Layer.provide(OrchestrationCommandReceiptRepositoryLive),66 Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive),
64 Layer.provide(RepositoryIdentityResolver.layer),67 Layer.provide(RepositoryIdentityResolver.layer),
65 Layer.provide(SqlitePersistenceMemory),68 Layer.provide(SqlitePersistenceMemory),
66 Layer.provideMerge(ServerConfigLayer),69 Layer.provideMerge(ServerConfigLayer),
67 Layer.provideMerge(NodeServices.layer),70 Layer.provideMerge(NodeServices.layer),
68 );71 );
69 const runtime = ManagedRuntime.make(orchestrationLayer);72}
73
74async function createOrchestrationSystem() {
75 const runtime = ManagedRuntime.make(makeOrchestrationLayer());
70 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));76 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
71 const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));77 const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
72 return {78 return {
...@@ -218,6 +224,7 @@ describe("OrchestrationEngine", () => {...@@ -218,6 +224,7 @@ describe("OrchestrationEngine", () => {
218 } satisfies OrchestrationProjectionPipelineShape),224 } satisfies OrchestrationProjectionPipelineShape),
219 ),225 ),
220 Layer.provide(Layer.succeed(OrchestrationEventStore, eventStore)),226 Layer.provide(Layer.succeed(OrchestrationEventStore, eventStore)),
227 Layer.provide(ThreadBackgroundLiveness.layer),
221 Layer.provide(OrchestrationCommandReceiptRepositoryLive),228 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
222 Layer.provide(SqlitePersistenceMemory),229 Layer.provide(SqlitePersistenceMemory),
223 Layer.provideMerge(NodeServices.layer),230 Layer.provideMerge(NodeServices.layer),
...@@ -243,6 +250,205 @@ describe("OrchestrationEngine", () => {...@@ -243,6 +250,205 @@ describe("OrchestrationEngine", () => {
243 await runtime.dispose();250 await runtime.dispose();
244 });251 });
245252
253 effectIt.effect("preserves the blocked-settle error and persists its rejected receipt", () =>
254 Effect.gen(function* () {
255 const engine = yield* OrchestrationEngineService;
256 const receipts = yield* OrchestrationCommandReceipts.OrchestrationCommandReceiptRepository;
257 const projectId = ProjectId.make("project-blocked-settle");
258 const threadId = ThreadId.make("thread-blocked-settle");
259 const commandId = CommandId.make("cmd-blocked-settle");
260 const createdAt = now();
261
262 yield* engine.dispatch({
263 type: "project.create",
264 commandId: CommandId.make("cmd-blocked-settle-project-create"),
265 projectId,
266 title: "Project",
267 workspaceRoot: "/tmp/project-blocked-settle",
268 createdAt,
269 });
270 yield* engine.dispatch({
271 type: "thread.create",
272 commandId: CommandId.make("cmd-blocked-settle-thread-create"),
273 threadId,
274 projectId,
275 title: "Thread",
276 modelSelection: {
277 instanceId: ProviderInstanceId.make("codex"),
278 model: "gpt-5-codex",
279 },
280 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
281 runtimeMode: "full-access",
282 branch: null,
283 worktreePath: null,
284 createdAt,
285 });
286 yield* engine.dispatch({
287 type: "thread.session.set",
288 commandId: CommandId.make("cmd-blocked-settle-session-set"),
289 threadId,
290 createdAt,
291 session: {
292 threadId,
293 status: "running",
294 providerName: "codex",
295 runtimeMode: "full-access",
296 activeTurnId: null,
297 lastError: null,
298 updatedAt: createdAt,
299 },
300 });
301
302 const sequence = yield* engine.latestSequence;
303 const error = yield* engine
304 .dispatch({ type: "thread.settle", commandId, threadId })
305 .pipe(Effect.flip);
306 const message =
307 "This thread still needs attention. Resolve or interrupt it first, then try again.";
308 expect(error).toMatchObject({
309 _tag: "OrchestrationThreadSettleBlockedError",
310 threadId,
311 message,
312 });
313 expect(Option.getOrNull(yield* receipts.getByCommandId({ commandId }))).toMatchObject({
314 commandId,
315 aggregateKind: "thread",
316 aggregateId: threadId,
317 status: "rejected",
318 error: message,
319 resultSequence: sequence,
320 });
321 expect(yield* engine.latestSequence).toBe(sequence);
322 }).pipe(Effect.provide(makeOrchestrationLayer())),
323 );
324
325 effectIt.effect(
326 "rejects persisted changes and live background work without blocking unrelated threads",
327 () =>
328 Effect.gen(function* () {
329 yield* TestClock.setTime(Date.parse(now()));
330 const engine = yield* OrchestrationEngineService;
331 const snapshots = yield* ProjectionSnapshotQuery;
332 const backgroundLiveness = yield* ThreadBackgroundLiveness.ThreadBackgroundLivenessService;
333 const projectId = ProjectId.make("project-auto-settle-guard");
334 const guardedThreadId = ThreadId.make("thread-auto-settle-guarded");
335 const unrelatedThreadId = ThreadId.make("thread-auto-settle-unrelated");
336 const liveThreadId = ThreadId.make("thread-auto-settle-live");
337
338 yield* engine.dispatch({
339 type: "project.create",
340 commandId: CommandId.make("cmd-auto-settle-guard-project"),
341 projectId,
342 title: "Project",
343 workspaceRoot: "/tmp/project-auto-settle-guard",
344 createdAt: now(),
345 });
346 for (const threadId of [guardedThreadId, unrelatedThreadId, liveThreadId]) {
347 yield* engine.dispatch({
348 type: "thread.create",
349 commandId: CommandId.make(`cmd-create-${threadId}`),
350 threadId,
351 projectId,
352 title: "Thread",
353 modelSelection: {
354 instanceId: ProviderInstanceId.make("codex"),
355 model: "gpt-5-codex",
356 },
357 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
358 runtimeMode: "full-access",
359 branch: null,
360 worktreePath: null,
361 createdAt: now(),
362 });
363 }
364
365 const beforeUpdate = yield* snapshots.getSnapshot();
366 const snapshotSequence = beforeUpdate.snapshotSequence;
367 const originalUpdatedAt = beforeUpdate.threads.find(
368 (thread) => thread.id === guardedThreadId,
369 )?.updatedAt;
370 yield* engine.dispatch({
371 type: "thread.meta.update",
372 commandId: CommandId.make("cmd-auto-settle-guard-meta"),
373 threadId: guardedThreadId,
374 branch: "new-branch",
375 });
376 const afterUpdate = yield* snapshots.getSnapshot();
377 expect(afterUpdate.threads.find((thread) => thread.id === guardedThreadId)?.updatedAt).toBe(
378 originalUpdatedAt,
379 );
380
381 const staleError = yield* engine
382 .dispatch({
383 type: "thread.auto-settle",
384 commandId: CommandId.make("cmd-auto-settle-stale-snapshot"),
385 threadId: guardedThreadId,
386 snapshotSequence,
387 })
388 .pipe(Effect.flip);
389 expect(staleError._tag).toBe("OrchestrationCommandInvariantError");
390
391 const livenessSnapshotSequence = yield* engine.latestSequence;
392 for (const [taskType, expectedLiveness] of [
393 ["subagent", "working"],
394 ["local_bash", "monitoring"],
395 ] as const) {
396 backgroundLiveness.recordTaskLiveness({
397 threadId: liveThreadId,
398 taskId: `task-${expectedLiveness}`,
399 taskType,
400 status: undefined,
401 kind: "started",
402 });
403 expect(backgroundLiveness.getThreadBackgroundLiveness(liveThreadId)).toBe(
404 expectedLiveness,
405 );
406 expect(yield* engine.latestSequence).toBe(livenessSnapshotSequence);
407
408 const livenessError = yield* engine
409 .dispatch({
410 type: "thread.auto-settle",
411 commandId: CommandId.make(`cmd-auto-settle-${expectedLiveness}`),
412 threadId: liveThreadId,
413 snapshotSequence: livenessSnapshotSequence,
414 })
415 .pipe(Effect.flip);
416 expect(livenessError._tag).toBe("OrchestrationCommandInvariantError");
417 expect(yield* engine.latestSequence).toBe(livenessSnapshotSequence);
418 backgroundLiveness.clearThreadLiveness(liveThreadId);
419 }
420
421 yield* engine.dispatch({
422 type: "thread.auto-settle",
423 commandId: CommandId.make("cmd-auto-settle-after-liveness-cleared"),
424 threadId: liveThreadId,
425 snapshotSequence: livenessSnapshotSequence,
426 });
427
428 const freshSnapshotSequence = yield* engine.latestSequence;
429 yield* engine.dispatch({
430 type: "thread.meta.update",
431 commandId: CommandId.make("cmd-auto-settle-unrelated-meta"),
432 threadId: unrelatedThreadId,
433 title: "Unrelated update",
434 });
435 yield* engine.dispatch({
436 type: "thread.auto-settle",
437 commandId: CommandId.make("cmd-auto-settle-after-unrelated-update"),
438 threadId: guardedThreadId,
439 snapshotSequence: freshSnapshotSequence,
440 });
441
442 const settled = yield* snapshots.getSnapshot();
443 expect(
444 settled.threads.find((thread) => thread.id === guardedThreadId)?.settledOverride,
445 ).toBe("settled");
446 expect(settled.threads.find((thread) => thread.id === liveThreadId)?.settledOverride).toBe(
447 "settled",
448 );
449 }).pipe(Effect.provide(makeOrchestrationLayer())),
450 );
451
246 it("persists deterministic read models for repeated snapshot reads", async () => {452 it("persists deterministic read models for repeated snapshot reads", async () => {
247 const createdAt = now();453 const createdAt = now();
248 const system = await createOrchestrationSystem();454 const system = await createOrchestrationSystem();
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+29-3
...@@ -33,6 +33,7 @@ import { toPersistenceSqlError } from "../../persistence/Errors.ts";...@@ -33,6 +33,7 @@ import { toPersistenceSqlError } from "../../persistence/Errors.ts";
33import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";33import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
34import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts";34import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts";
35import {35import {
36 isOrchestrationCommandRejection,
36 OrchestrationCommandIdConflictError,37 OrchestrationCommandIdConflictError,
37 OrchestrationCommandInvariantError,38 OrchestrationCommandInvariantError,
38 OrchestrationCommandPreviouslyRejectedError,39 OrchestrationCommandPreviouslyRejectedError,
...@@ -43,6 +44,7 @@ import { decideOrchestrationCommand } from "../decider.ts";...@@ -43,6 +44,7 @@ import { decideOrchestrationCommand } from "../decider.ts";
43import { createEmptyReadModel, projectEvent } from "../projector.ts";44import { createEmptyReadModel, projectEvent } from "../projector.ts";
44import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";45import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
45import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";46import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
47import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
46import {48import {
47 OrchestrationEngineService,49 OrchestrationEngineService,
48 type OrchestrationEngineShape,50 type OrchestrationEngineShape,
...@@ -51,7 +53,6 @@ const isOrchestrationCommandPreviouslyRejectedError = Schema.is(...@@ -51,7 +53,6 @@ const isOrchestrationCommandPreviouslyRejectedError = Schema.is(
51 OrchestrationCommandPreviouslyRejectedError,53 OrchestrationCommandPreviouslyRejectedError,
52);54);
53const isOrchestrationCommandIdConflictError = Schema.is(OrchestrationCommandIdConflictError);55const isOrchestrationCommandIdConflictError = Schema.is(OrchestrationCommandIdConflictError);
54const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvariantError);
5556
56interface CommandEnvelope {57interface CommandEnvelope {
57 command: OrchestrationCommand;58 command: OrchestrationCommand;
...@@ -86,6 +87,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -86,6 +87,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
86 const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository;87 const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository;
87 const projectionPipeline = yield* OrchestrationProjectionPipeline;88 const projectionPipeline = yield* OrchestrationProjectionPipeline;
88 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;89 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
90 const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService;
89 const crypto = yield* Crypto.Crypto;91 const crypto = yield* Crypto.Crypto;
9092
91 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);93 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
...@@ -169,13 +171,37 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -169,13 +171,37 @@ const makeOrchestrationEngine = Effect.gen(function* () {
169 });171 });
170 }172 }
171173
174 if (
175 envelope.command.type === "thread.auto-settle" &&
176 (yield* eventStore.hasEventAfter({
177 aggregateKind: "thread",
178 aggregateId: envelope.command.threadId,
179 sequenceExclusive: envelope.command.snapshotSequence,
180 }))
181 ) {
182 return yield* new OrchestrationCommandInvariantError({
183 commandType: envelope.command.type,
184 detail: `thread ${envelope.command.threadId} changed before automatic settlement`,
185 });
186 }
187
188 if (
189 envelope.command.type === "thread.auto-settle" &&
190 threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== null
191 ) {
192 return yield* new OrchestrationCommandInvariantError({
193 commandType: envelope.command.type,
194 detail: `thread ${envelope.command.threadId} has live background work`,
195 });
196 }
197
172 const eventBase = yield* decideOrchestrationCommand({198 const eventBase = yield* decideOrchestrationCommand({
173 command: envelope.command,199 command: envelope.command,
174 readModel: commandReadModel,200 readModel: commandReadModel,
175 }).pipe(201 }).pipe(
176 Effect.provideService(Crypto.Crypto, crypto),202 Effect.provideService(Crypto.Crypto, crypto),
177 Effect.mapError((cause) =>203 Effect.mapError((cause) =>
178 isOrchestrationCommandInvariantError(cause)204 isOrchestrationCommandRejection(cause)
179 ? cause205 ? cause
180 : new OrchestrationCommandInvariantError({206 : new OrchestrationCommandInvariantError({
181 commandType: envelope.command.type,207 commandType: envelope.command.type,
...@@ -307,7 +333,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -307,7 +333,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
307 ),333 ),
308 );334 );
309335
310 if (isOrchestrationCommandInvariantError(error)) {336 if (isOrchestrationCommandRejection(error)) {
311 yield* commandReceiptRepository337 yield* commandReceiptRepository
312 .upsert({338 .upsert({
313 commandId: envelope.command.commandId,339 commandId: envelope.command.commandId,
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+12-1
...@@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts";...@@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts";
9import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";9import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
10import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";10import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
12import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
12import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";13import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
13import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";14import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
14import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";15import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
...@@ -23,7 +24,7 @@ describe("OrchestrationReactor", () => {...@@ -23,7 +24,7 @@ describe("OrchestrationReactor", () => {
23 runtime = null;24 runtime = null;
24 });25 });
2526
26 it("starts provider ingestion, provider command, checkpoint, and thread deletion reactors", async () => {27 it("starts every orchestration reactor", async () => {
27 const started: string[] = [];28 const started: string[] = [];
2829
29 runtime = ManagedRuntime.make(30 runtime = ManagedRuntime.make(
...@@ -64,6 +65,15 @@ describe("OrchestrationReactor", () => {...@@ -64,6 +65,15 @@ describe("OrchestrationReactor", () => {
64 drainThrough: () => Effect.void,65 drainThrough: () => Effect.void,
65 }),66 }),
66 ),67 ),
68 Layer.provideMerge(
69 Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, {
70 start: () => {
71 started.push("thread-settlement-reactor");
72 return Effect.void;
73 },
74 drain: Effect.void,
75 }),
76 ),
67 Layer.provideMerge(77 Layer.provideMerge(
68 Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {78 Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {
69 publishThread: () => Effect.void,79 publishThread: () => Effect.void,
...@@ -85,6 +95,7 @@ describe("OrchestrationReactor", () => {...@@ -85,6 +95,7 @@ describe("OrchestrationReactor", () => {
85 "provider-command-reactor",95 "provider-command-reactor",
86 "checkpoint-reactor",96 "checkpoint-reactor",
87 "thread-deletion-reactor",97 "thread-deletion-reactor",
98 "thread-settlement-reactor",
88 "agent-awareness-relay",99 "agent-awareness-relay",
89 ]);100 ]);
90101
apps/server/src/orchestration/Layers/OrchestrationReactor.ts+3
...@@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts";...@@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts";
9import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";9import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
10import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";10import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
12import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
12import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";13import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
1314
14export const makeOrchestrationReactor = Effect.gen(function* () {15export const makeOrchestrationReactor = Effect.gen(function* () {
...@@ -16,6 +17,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {...@@ -16,6 +17,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
16 const providerCommandReactor = yield* ProviderCommandReactor;17 const providerCommandReactor = yield* ProviderCommandReactor;
17 const checkpointReactor = yield* CheckpointReactor;18 const checkpointReactor = yield* CheckpointReactor;
18 const threadDeletionReactor = yield* ThreadDeletionReactor;19 const threadDeletionReactor = yield* ThreadDeletionReactor;
20 const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
19 const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;21 const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
2022
21 const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () {23 const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () {
...@@ -23,6 +25,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {...@@ -23,6 +25,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
23 yield* providerCommandReactor.start();25 yield* providerCommandReactor.start();
24 yield* checkpointReactor.start();26 yield* checkpointReactor.start();
25 yield* threadDeletionReactor.start();27 yield* threadDeletionReactor.start();
28 yield* threadSettlementReactor.start();
26 yield* agentAwarenessRelay.start();29 yield* agentAwarenessRelay.start();
27 });30 });
2831
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+45
...@@ -3229,4 +3229,49 @@ describe("ProviderCommandReactor", () => {...@@ -3229,4 +3229,49 @@ describe("ProviderCommandReactor", () => {
3229 expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work"));3229 expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work"));
3230 expect(thread?.session?.activeTurnId).toBeNull();3230 expect(thread?.session?.activeTurnId).toBeNull();
3231 });3231 });
3232
3233 effectIt.effect("stops a ready provider session after automatic settlement", () =>
3234 Effect.gen(function* () {
3235 const sessionStopped = yield* Deferred.make<void>();
3236 const harness = yield* Effect.promise(() =>
3237 createHarness({
3238 stopSessionEffect: () => Deferred.succeed(sessionStopped, undefined).pipe(Effect.asVoid),
3239 }),
3240 );
3241 const now = "2026-01-01T00:00:00.000Z";
3242
3243 yield* harness.engine.dispatch({
3244 type: "thread.session.set",
3245 commandId: CommandId.make("cmd-session-set-for-auto-settle"),
3246 threadId: ThreadId.make("thread-1"),
3247 session: {
3248 threadId: ThreadId.make("thread-1"),
3249 status: "ready",
3250 providerName: "codex",
3251 providerInstanceId: ProviderInstanceId.make("codex_work"),
3252 runtimeMode: "approval-required",
3253 activeTurnId: null,
3254 lastError: null,
3255 updatedAt: now,
3256 },
3257 createdAt: now,
3258 });
3259 const beforeSettlement = yield* Effect.promise(() => harness.readModel());
3260
3261 yield* harness.engine.dispatch({
3262 type: "thread.auto-settle",
3263 commandId: CommandId.make("cmd-auto-settle-with-session"),
3264 threadId: ThreadId.make("thread-1"),
3265 snapshotSequence: beforeSettlement.snapshotSequence,
3266 });
3267
3268 yield* Deferred.await(sessionStopped);
3269 yield* Effect.promise(() => harness.drain());
3270 const readModel = yield* Effect.promise(() => harness.readModel());
3271 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
3272 expect(thread?.settledOverride).toBe("settled");
3273 expect(thread?.session?.status).toBe("stopped");
3274 expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work"));
3275 }),
3276 );
3232});3277});
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+22-2
...@@ -61,7 +61,8 @@ type ProviderIntentEvent = Extract<...@@ -61,7 +61,8 @@ type ProviderIntentEvent = Extract<
61 | "thread.turn-interrupt-requested"61 | "thread.turn-interrupt-requested"
62 | "thread.approval-response-requested"62 | "thread.approval-response-requested"
63 | "thread.user-input-response-requested"63 | "thread.user-input-response-requested"
64 | "thread.session-stop-requested";64 | "thread.session-stop-requested"
65 | "thread.settled";
65 }66 }
66>;67>;
6768
...@@ -1490,6 +1491,24 @@ const make = Effect.gen(function* () {...@@ -1490,6 +1491,24 @@ const make = Effect.gen(function* () {
1490 case "thread.session-stop-requested":1491 case "thread.session-stop-requested":
1491 yield* processSessionStopRequested(event);1492 yield* processSessionStopRequested(event);
1492 return;1493 return;
1494 case "thread.settled": {
1495 const thread = yield* projectionSnapshotQuery.getThreadShellById(event.payload.threadId);
1496 if (
1497 Option.isNone(thread) ||
1498 thread.value.session == null ||
1499 thread.value.session.status === "stopped"
1500 ) {
1501 return;
1502 }
1503 yield* orchestrationEngine.dispatch({
1504 type: "thread.session.stop",
1505 commandId: CommandId.make(`session-stop-for-settle:${event.commandId ?? event.eventId}`),
1506 threadId: event.payload.threadId,
1507 createdAt: event.occurredAt,
1508 onlyIfSettled: true,
1509 });
1510 return;
1511 }
1493 }1512 }
1494 });1513 });
14951514
...@@ -1528,7 +1547,8 @@ const make = Effect.gen(function* () {...@@ -1528,7 +1547,8 @@ const make = Effect.gen(function* () {
1528 event.type === "thread.turn-interrupt-requested" ||1547 event.type === "thread.turn-interrupt-requested" ||
1529 event.type === "thread.approval-response-requested" ||1548 event.type === "thread.approval-response-requested" ||
1530 event.type === "thread.user-input-response-requested" ||1549 event.type === "thread.user-input-response-requested" ||
1531 event.type === "thread.session-stop-requested"1550 event.type === "thread.session-stop-requested" ||
1551 event.type === "thread.settled"
1532 ) {1552 ) {
1533 return yield* worker.enqueue(event);1553 return yield* worker.enqueue(event);
1534 }1554 }
apps/server/src/orchestration/ThreadSettlementPolicy.test.ts created+159
...@@ -0,0 +1,159 @@
1import { describe, expect, it } from "vite-plus/test";
2import {
3 ProviderInstanceId,
4 ThreadId,
5 ProjectId,
6 TurnId,
7 type OrchestrationThreadShell,
8} from "@t3tools/contracts";
9import { shouldAutoSettleThread } from "./ThreadSettlementPolicy.ts";
10
11const NOW = "2026-08-28T12:00:00.000Z";
12const makeThread = (
13 overrides: Partial<OrchestrationThreadShell> = {},
14): OrchestrationThreadShell => ({
15 id: ThreadId.make("thread-1"),
16 projectId: ProjectId.make("project-1"),
17 title: "Thread",
18 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
19 runtimeMode: "full-access",
20 interactionMode: "default",
21 branch: "feature",
22 worktreePath: "/repo",
23 latestTurn: null,
24 createdAt: "2026-08-01T00:00:00.000Z",
25 updatedAt: "2026-08-20T00:00:00.000Z",
26 archivedAt: null,
27 settledOverride: null,
28 settledAt: null,
29 session: null,
30 latestUserMessageAt: "2026-08-20T00:00:00.000Z",
31 hasPendingApprovals: false,
32 hasPendingUserInput: false,
33 hasActionableProposedPlan: false,
34 ...overrides,
35});
36
37const decide = (
38 thread: OrchestrationThreadShell,
39 pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null,
40 settings: { days?: number | null; merge?: boolean } = {},
41) =>
42 shouldAutoSettleThread({
43 thread,
44 pullRequest,
45 now: NOW,
46 autoSettleAfterDays: settings.days === undefined ? 3 : settings.days,
47 autoSettleOnMerge: settings.merge ?? true,
48 });
49
50describe("shouldAutoSettleThread", () => {
51 it("settles inactive threads and leaves never-used threads active", () => {
52 expect(decide(makeThread())).toBe(true);
53 expect(decide(makeThread({ latestUserMessageAt: null }))).toBe(false);
54 expect(decide(makeThread(), null, { days: null })).toBe(false);
55 });
56
57 it("keeps a thread active at the exact inactivity boundary", () => {
58 expect(decide(makeThread({ latestUserMessageAt: "2026-08-25T12:00:00.000Z" }))).toBe(false);
59 });
60
61 it("keeps open pull requests active", () => {
62 expect(decide(makeThread(), { state: "open", updatedAt: NOW })).toBe(false);
63 });
64
65 it("settles closed requests and honors the merge setting", () => {
66 expect(decide(makeThread(), { state: "closed", updatedAt: NOW }, { merge: false })).toBe(true);
67 expect(decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false })).toBe(true);
68 expect(
69 decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false, days: null }),
70 ).toBe(false);
71 });
72
73 it("does not settle again after user activity newer than the PR", () => {
74 expect(
75 decide(
76 makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }),
77 { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" },
78 { days: null },
79 ),
80 ).toBe(false);
81 });
82
83 it("does not inherit a terminal pull request older than the thread", () => {
84 expect(
85 decide(
86 makeThread({ createdAt: "2026-08-20T00:00:00.000Z", latestUserMessageAt: null }),
87 { state: "closed", updatedAt: "2026-08-19T00:00:00.000Z" },
88 { days: null },
89 ),
90 ).toBe(false);
91 });
92
93 it("requires a comparable PR timestamp for immediate settlement", () => {
94 const recentThread = makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" });
95 expect(decide(recentThread, { state: "closed", updatedAt: null })).toBe(false);
96 expect(decide(recentThread, { state: "merged", updatedAt: "unknown" })).toBe(false);
97 expect(decide(makeThread(), { state: "closed", updatedAt: null })).toBe(true);
98 });
99
100 it("uses user request time instead of completion time as the PR anchor", () => {
101 const thread = makeThread({
102 latestTurn: {
103 turnId: TurnId.make("turn-1"),
104 state: "completed",
105 requestedAt: "2026-08-25T00:00:00.000Z",
106 startedAt: "2026-08-25T00:01:00.000Z",
107 completedAt: "2026-08-27T00:00:00.000Z",
108 assistantMessageId: null,
109 },
110 });
111 expect(decide(thread, { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" })).toBe(true);
112 });
113
114 it("blocks pins, snooze, pending work, live sessions, and queued starts", () => {
115 expect(decide(makeThread({ settledOverride: "active" }))).toBe(false);
116 expect(decide(makeThread({ snoozedUntil: "2026-08-29T00:00:00.000Z" }))).toBe(false);
117 expect(decide(makeThread({ hasPendingApprovals: true }))).toBe(false);
118 expect(decide(makeThread({ hasPendingUserInput: true }))).toBe(false);
119 expect(decide(makeThread({ backgroundLiveness: "working" }))).toBe(false);
120 expect(decide(makeThread({ backgroundLiveness: "monitoring" }))).toBe(false);
121 expect(
122 decide(
123 makeThread({
124 session: {
125 threadId: ThreadId.make("thread-1"),
126 status: "running",
127 providerName: "codex",
128 runtimeMode: "full-access",
129 activeTurnId: TurnId.make("turn-1"),
130 lastError: null,
131 updatedAt: NOW,
132 },
133 }),
134 ),
135 ).toBe(false);
136 expect(
137 decide(makeThread({ latestUserMessageAt: "2026-08-28T11:59:00.000Z", latestTurn: null })),
138 ).toBe(false);
139 });
140
141 it("allows a fresh completion to wake snooze before settlement", () => {
142 expect(
143 decide(
144 makeThread({
145 snoozedAt: "2026-08-19T00:00:00.000Z",
146 snoozedUntil: "2026-08-29T00:00:00.000Z",
147 latestTurn: {
148 turnId: TurnId.make("turn-woke"),
149 state: "completed",
150 requestedAt: "2026-08-18T00:00:00.000Z",
151 startedAt: "2026-08-18T00:01:00.000Z",
152 completedAt: "2026-08-20T00:00:00.000Z",
153 assistantMessageId: null,
154 },
155 }),
156 ),
157 ).toBe(true);
158 });
159});
apps/server/src/orchestration/ThreadSettlementPolicy.ts created+108
...@@ -0,0 +1,108 @@
1import type { OrchestrationThreadShell } from "@t3tools/contracts";
2
3export interface SettlementPullRequest {
4 readonly state: "open" | "closed" | "merged";
5 readonly updatedAt: string | null;
6}
7
8const DAY_MS = 24 * 60 * 60 * 1_000;
9export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;
10
11function latestTimestamp(values: ReadonlyArray<string | null | undefined>): string | null {
12 let latest: string | null = null;
13 let latestMs = Number.NEGATIVE_INFINITY;
14 for (const value of values) {
15 if (value == null) continue;
16 const valueMs = Date.parse(value);
17 if (valueMs > latestMs) {
18 latest = value;
19 latestMs = valueMs;
20 }
21 }
22 return latest;
23}
24
25/** A recent user message stays queued until a turn adopts its timestamp.
26 * Absolute age bounds client clock skew in both directions and stops stale
27 * pre-adoption data from blocking the thread forever. */
28export function threadHasQueuedTurnStart(
29 thread: Pick<OrchestrationThreadShell, "latestUserMessageAt" | "latestTurn" | "session">,
30 now: string,
31): boolean {
32 if (thread.latestUserMessageAt === null || thread.session?.status === "error") return false;
33 const messageAt = Date.parse(thread.latestUserMessageAt);
34 const age = Date.parse(now) - messageAt;
35 if (Number.isNaN(age) || Math.abs(age) > QUEUED_TURN_START_GRACE_MS) return false;
36 if (thread.latestTurn === null) return true;
37 return [
38 thread.latestTurn.requestedAt,
39 thread.latestTurn.startedAt,
40 thread.latestTurn.completedAt,
41 ].every((value) => value == null || Date.parse(value) < messageAt);
42}
43
44function pullRequestSettles(
45 thread: Pick<OrchestrationThreadShell, "createdAt" | "latestUserMessageAt" | "latestTurn">,
46 pullRequest: SettlementPullRequest,
47 autoSettleOnMerge: boolean,
48): boolean {
49 if (pullRequest.state !== "closed" && (pullRequest.state !== "merged" || !autoSettleOnMerge)) {
50 return false;
51 }
52 if (pullRequest.updatedAt === null) return false;
53 const userAnchor = latestTimestamp([
54 thread.createdAt,
55 thread.latestUserMessageAt,
56 thread.latestTurn?.requestedAt,
57 ]);
58 if (userAnchor === null) return false;
59 const pullRequestAt = Date.parse(pullRequest.updatedAt);
60 const userAnchorAt = Date.parse(userAnchor);
61 if (Number.isNaN(pullRequestAt) || Number.isNaN(userAnchorAt)) return false;
62 return pullRequestAt >= userAnchorAt;
63}
64
65export function shouldAutoSettleThread(input: {
66 readonly thread: OrchestrationThreadShell;
67 readonly pullRequest: SettlementPullRequest | null;
68 readonly now: string;
69 readonly autoSettleAfterDays: number | null;
70 readonly autoSettleOnMerge: boolean;
71}): boolean {
72 const { thread, pullRequest } = input;
73 if (!isAutoSettlementCandidate(thread, input.now)) return false;
74 if (pullRequest !== null) {
75 if (pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) return true;
76 if (pullRequest.state === "open") return false;
77 }
78 if (input.autoSettleAfterDays === null) return false;
79 const activityAt = latestTimestamp([
80 thread.latestUserMessageAt,
81 thread.latestTurn?.requestedAt,
82 thread.latestTurn?.startedAt,
83 thread.latestTurn?.completedAt,
84 ]);
85 if (activityAt === null) return false;
86 return Date.parse(activityAt) < Date.parse(input.now) - input.autoSettleAfterDays * DAY_MS;
87}
88
89/** Cheap checks that run before any source control lookup. */
90export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now: string): boolean {
91 if (thread.archivedAt !== null || thread.settledOverride !== null) return false;
92 if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false;
93 if (thread.session?.status === "starting" || thread.session?.status === "running") return false;
94 if (thread.backgroundLiveness != null) return false;
95 if (threadHasQueuedTurnStart(thread, now)) return false;
96 if (thread.snoozedUntil == null || Date.parse(thread.snoozedUntil) <= Date.parse(now))
97 return true;
98 const wokeOnError =
99 thread.session?.status === "error" &&
100 (thread.snoozedAt == null ||
101 Date.parse(thread.session.updatedAt) > Date.parse(thread.snoozedAt));
102 const wokeOnCompletion =
103 thread.snoozedAt != null &&
104 thread.latestTurn?.state === "completed" &&
105 thread.latestTurn.completedAt != null &&
106 Date.parse(thread.latestTurn.completedAt) > Date.parse(thread.snoozedAt);
107 return wokeOnError || wokeOnCompletion;
108}
apps/server/src/orchestration/ThreadSettlementReactor.test.ts created+640
...@@ -0,0 +1,640 @@
1import {
2 DEFAULT_SERVER_SETTINGS,
3 ProjectId,
4 ProviderInstanceId,
5 PullRequestOperationError,
6 ThreadId,
7 type OrchestrationCommand,
8 type OrchestrationProjectShell,
9 type OrchestrationShellSnapshot,
10 type OrchestrationThreadShell,
11 type PullRequestDetail,
12 type ServerSettings,
13 type ServerSettingsPatch,
14} from "@t3tools/contracts";
15import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
16import { assert, describe, it } from "@effect/vitest";
17import * as Crypto from "effect/Crypto";
18import * as Deferred from "effect/Deferred";
19import * as Effect from "effect/Effect";
20import * as Layer from "effect/Layer";
21import * as PubSub from "effect/PubSub";
22import * as Queue from "effect/Queue";
23import * as Ref from "effect/Ref";
24import * as Stream from "effect/Stream";
25import { TestClock } from "effect/testing";
26
27import { GitManager } from "../git/GitManager.ts";
28import { PullRequestService } from "../pullRequest/PullRequestService.ts";
29import { ServerActivation } from "../serverActivation.ts";
30import { ServerSettingsService } from "../serverSettings.ts";
31import { OrchestrationCommandInvariantError } from "./Errors.ts";
32import {
33 OrchestrationEngineService,
34 type OrchestrationEngineShape,
35} from "./Services/OrchestrationEngine.ts";
36import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
37import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts";
38
39const NOW = "2026-08-28T12:00:00.000Z";
40const PROJECT_ID = ProjectId.make("settlement-project");
41const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project");
42
43type AutoSettleCommand = Extract<OrchestrationCommand, { readonly type: "thread.auto-settle" }>;
44
45const testCrypto = Crypto.make({
46 randomBytes: (size) => new Uint8Array(size).fill(1),
47 digest: (_algorithm, data) => Effect.succeed(data),
48});
49
50function makeProject(
51 id: ProjectId = PROJECT_ID,
52 workspaceRoot = "/workspace/project",
53): OrchestrationProjectShell {
54 return {
55 id,
56 title: `Project ${id}`,
57 workspaceRoot,
58 defaultModelSelection: null,
59 scripts: [],
60 createdAt: "2026-08-01T00:00:00.000Z",
61 updatedAt: NOW,
62 };
63}
64
65function makeThread(
66 id: string,
67 overrides: Partial<OrchestrationThreadShell> = {},
68): OrchestrationThreadShell {
69 return {
70 id: ThreadId.make(id),
71 projectId: PROJECT_ID,
72 title: id,
73 modelSelection: {
74 instanceId: ProviderInstanceId.make("codex"),
75 model: "gpt-5",
76 },
77 runtimeMode: "full-access",
78 interactionMode: "default",
79 branch: null,
80 worktreePath: null,
81 latestTurn: null,
82 createdAt: "2026-08-01T00:00:00.000Z",
83 updatedAt: "2026-08-20T00:00:00.000Z",
84 archivedAt: null,
85 settledOverride: null,
86 settledAt: null,
87 session: null,
88 latestUserMessageAt: "2026-08-20T00:00:00.000Z",
89 hasPendingApprovals: false,
90 hasPendingUserInput: false,
91 hasActionableProposedPlan: false,
92 ...overrides,
93 };
94}
95
96function makeSnapshot(
97 threads: ReadonlyArray<OrchestrationThreadShell>,
98 projects: ReadonlyArray<OrchestrationProjectShell> = [makeProject()],
99): OrchestrationShellSnapshot {
100 return {
101 snapshotSequence: 1,
102 projects,
103 threads,
104 updatedAt: NOW,
105 };
106}
107
108function makePullRequestDetail(input: {
109 readonly projectId: ProjectId;
110 readonly repository: string;
111 readonly number: number;
112 readonly state: "open" | "closed" | "merged";
113 readonly updatedAt?: string;
114}): PullRequestDetail {
115 return {
116 provider: "github",
117 capabilities: {
118 diff: true,
119 comment: true,
120 actions: [],
121 mergeMethods: [],
122 search: true,
123 review: { inlineComment: true, reply: true, resolve: true, verdicts: [] },
124 reviewers: { request: true, listCandidates: true },
125 },
126 viewerPermissions: {
127 actions: [],
128 comment: true,
129 resolve: true,
130 verdicts: [],
131 requestReviewers: true,
132 },
133 projectId: input.projectId,
134 projectTitle: "Linked project",
135 workspaceRoot: "/workspace/linked",
136 repository: input.repository,
137 number: input.number,
138 title: "Pull request",
139 body: "",
140 url: `https://example.test/${input.repository}/pull/${input.number}`,
141 author: null,
142 state: input.state,
143 isDraft: false,
144 mergeability: "mergeable",
145 additions: 0,
146 deletions: 0,
147 changedFiles: 0,
148 headBranch: "feature",
149 baseBranch: "main",
150 createdAt: "2026-08-01T00:00:00.000Z",
151 updatedAt: input.updatedAt ?? NOW,
152 mergedAt: input.state === "merged" ? (input.updatedAt ?? NOW) : null,
153 closedAt: input.state === "closed" ? (input.updatedAt ?? NOW) : null,
154 reviewers: [],
155 labels: [],
156 checks: [],
157 mergeCapabilities: { merge: true, squash: true, rebase: true },
158 };
159}
160
161interface HarnessOptions {
162 readonly snapshot: OrchestrationShellSnapshot;
163 readonly settings?: ServerSettings;
164 readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"];
165 readonly pullRequestDetail?: PullRequestService["Service"]["detail"];
166 readonly onDispatch?: (
167 command: AutoSettleCommand,
168 ) => Effect.Effect<void, OrchestrationCommandInvariantError>;
169}
170
171const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: HarnessOptions) {
172 const activation = yield* Deferred.make<void>();
173 const snapshots = yield* Ref.make(options.snapshot);
174 const snapshotReadCount = yield* Ref.make(0);
175 const snapshotReads = yield* Queue.unbounded<number>();
176 const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS);
177 const settingsChanges = yield* PubSub.unbounded<ServerSettings>();
178 const commands = yield* Ref.make<ReadonlyArray<AutoSettleCommand>>([]);
179 const branchCalls = yield* Ref.make<
180 ReadonlyArray<{ readonly cwd: string; readonly branch: string }>
181 >([]);
182 const detailCalls = yield* Ref.make<
183 ReadonlyArray<{
184 readonly projectId: ProjectId;
185 readonly repository: string;
186 readonly number: number;
187 }>
188 >([]);
189
190 const updateSettings = (patch: ServerSettingsPatch) =>
191 Effect.gen(function* () {
192 const next = applyServerSettingsPatch(yield* Ref.get(settings), patch);
193 yield* Ref.set(settings, next);
194 yield* PubSub.publish(settingsChanges, next);
195 return next;
196 });
197
198 const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) =>
199 Ref.update(branchCalls, (calls) => [...calls, input]).pipe(
200 Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)),
201 );
202
203 const pullRequestDetail: PullRequestService["Service"]["detail"] = (input) =>
204 Ref.update(detailCalls, (calls) => [...calls, input]).pipe(
205 Effect.andThen(
206 options.pullRequestDetail?.(input) ??
207 Effect.succeed(
208 makePullRequestDetail({
209 ...input,
210 state: "open",
211 }),
212 ),
213 ),
214 );
215
216 const dispatch: OrchestrationEngineShape["dispatch"] = (command) => {
217 if (command.type !== "thread.auto-settle") {
218 return Effect.die(new Error(`Unexpected command: ${command.type}`));
219 }
220 return Ref.update(commands, (recorded) => [...recorded, command]).pipe(
221 Effect.andThen(options.onDispatch?.(command) ?? Effect.void),
222 Effect.as({ sequence: 1 }),
223 );
224 };
225
226 const serverSettings = ServerSettingsService.of({
227 start: Effect.void,
228 ready: Effect.void,
229 getSettings: Ref.get(settings),
230 updateSettings,
231 streamChanges: Stream.fromPubSub(settingsChanges),
232 subscribeChanges: PubSub.subscribe(settingsChanges).pipe(
233 Effect.map((subscription) => Stream.fromSubscription(subscription)),
234 ),
235 });
236
237 const dependencies = Layer.mergeAll(
238 Layer.mock(ProjectionSnapshotQuery)({
239 getShellSnapshot: () =>
240 Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe(
241 Effect.tap((count) => Queue.offer(snapshotReads, count)),
242 Effect.andThen(Ref.get(snapshots)),
243 ),
244 }),
245 Layer.mock(GitManager)({ branchPullRequest }),
246 Layer.mock(PullRequestService)({ detail: pullRequestDetail }),
247 Layer.mock(OrchestrationEngineService)({
248 readEvents: () => Stream.empty,
249 dispatch,
250 streamDomainEvents: Stream.empty,
251 latestSequence: Effect.succeed(0),
252 }),
253 Layer.succeed(ServerSettingsService, serverSettings),
254 Layer.succeed(ServerActivation, Deferred.await(activation)),
255 Layer.succeed(Crypto.Crypto, testCrypto),
256 );
257
258 return {
259 activation,
260 snapshots,
261 snapshotReadCount,
262 snapshotReads,
263 commands,
264 branchCalls,
265 detailCalls,
266 updateSettings,
267 layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)),
268 };
269});
270
271const startHarness = Effect.fn("startThreadSettlementHarness")(function* (
272 reactor: ThreadSettlementReactor.ThreadSettlementReactor["Service"],
273 activation: Deferred.Deferred<void>,
274 snapshotReads: Queue.Queue<number>,
275) {
276 yield* reactor.start();
277 yield* Deferred.succeed(activation, undefined);
278 yield* Queue.take(snapshotReads);
279 yield* reactor.drain;
280});
281
282describe("ThreadSettlementReactor", () => {
283 it.effect("starts without clients and skips protected threads before pull request lookup", () =>
284 Effect.scoped(
285 Effect.gen(function* () {
286 yield* TestClock.setTime(Date.parse(NOW));
287 const linkedPullRequest = {
288 projectId: LINKED_PROJECT_ID,
289 repository: "owner/repository",
290 number: 42,
291 url: "https://example.test/owner/repository/pull/42",
292 } as const;
293 const skipped = [
294 makeThread("pending-approval", {
295 branch: "skip-approval",
296 hasPendingApprovals: true,
297 }),
298 makeThread("snoozed", {
299 branch: "skip-snoozed",
300 snoozedUntil: "2026-08-29T00:00:00.000Z",
301 }),
302 ];
303 const fixture = yield* makeHarness({
304 snapshot: makeSnapshot(
305 [
306 makeThread("inactive", { branch: "inactive-feature" }),
307 makeThread("closed-pr", { linkedPullRequest }),
308 ...skipped,
309 ],
310 [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
311 ),
312 branchPullRequest: () => Effect.succeed(null),
313 pullRequestDetail: (input) =>
314 Effect.succeed(makePullRequestDetail({ ...input, state: "closed" })),
315 });
316
317 yield* Effect.gen(function* () {
318 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
319 yield* reactor.start();
320 assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 0);
321
322 yield* Deferred.succeed(fixture.activation, undefined);
323 yield* Queue.take(fixture.snapshotReads);
324 yield* reactor.drain;
325
326 const commands = yield* Ref.get(fixture.commands);
327 assert.deepStrictEqual(
328 commands
329 .map(({ threadId, snapshotSequence }) => ({ threadId, snapshotSequence }))
330 .sort((left, right) => left.threadId.localeCompare(right.threadId)),
331 [
332 {
333 threadId: ThreadId.make("closed-pr"),
334 snapshotSequence: 1,
335 },
336 {
337 threadId: ThreadId.make("inactive"),
338 snapshotSequence: 1,
339 },
340 ],
341 );
342 assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [
343 { cwd: "/workspace/project", branch: "inactive-feature" },
344 ]);
345 assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [
346 { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 },
347 ]);
348 }).pipe(Effect.provide(fixture.layer));
349 }),
350 ),
351 );
352
353 it.effect("reevaluates inactivity and pull request state once per minute", () =>
354 Effect.scoped(
355 Effect.gen(function* () {
356 yield* TestClock.setTime(Date.parse(NOW));
357 const pullRequest = yield* Ref.make<"open" | "merged">("open");
358 const fixture = yield* makeHarness({
359 snapshot: makeSnapshot([
360 makeThread("at-boundary", {
361 latestUserMessageAt: "2026-08-25T12:00:00.000Z",
362 }),
363 makeThread("open-pr", {
364 branch: "saved-feature",
365 latestUserMessageAt: "2026-08-27T00:00:00.000Z",
366 }),
367 ]),
368 branchPullRequest: () =>
369 Ref.get(pullRequest).pipe(Effect.map((state) => ({ state, updatedAt: NOW }))),
370 });
371
372 yield* Effect.gen(function* () {
373 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
374 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
375 assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
376
377 yield* Ref.set(pullRequest, "merged");
378 yield* TestClock.adjust("1 minute");
379 yield* Queue.take(fixture.snapshotReads);
380 yield* reactor.drain;
381
382 assert.deepStrictEqual(
383 (yield* Ref.get(fixture.commands))
384 .map((command) => command.threadId)
385 .sort((left, right) => left.localeCompare(right)),
386 [ThreadId.make("at-boundary"), ThreadId.make("open-pr")],
387 );
388 assert.strictEqual((yield* Ref.get(fixture.branchCalls)).length, 2);
389 }).pipe(Effect.provide(fixture.layer));
390 }),
391 ),
392 );
393
394 it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () =>
395 Effect.scoped(
396 Effect.gen(function* () {
397 yield* TestClock.setTime(Date.parse(NOW));
398 const state = yield* Ref.make<"merged" | "closed">("merged");
399 const firstLookupStarted = yield* Deferred.make<void>();
400 const releaseFirstLookup = yield* Deferred.make<void>();
401 const laterLookupStarted = yield* Deferred.make<void>();
402 const releaseLaterLookup = yield* Deferred.make<void>();
403 const lookupCount = yield* Ref.make(0);
404 const fixture = yield* makeHarness({
405 snapshot: makeSnapshot([makeThread("settings-thread", { branch: "saved-feature" })]),
406 settings: {
407 ...DEFAULT_SERVER_SETTINGS,
408 sidebarAutoSettleAfterDays: null,
409 sidebarAutoSettleOnMerge: true,
410 },
411 branchPullRequest: () =>
412 Ref.updateAndGet(lookupCount, (count) => count + 1).pipe(
413 Effect.tap((count) =>
414 count === 1
415 ? Deferred.succeed(firstLookupStarted, undefined)
416 : count === 3
417 ? Deferred.succeed(laterLookupStarted, undefined)
418 : Effect.void,
419 ),
420 Effect.tap((count) =>
421 count === 1
422 ? Deferred.await(releaseFirstLookup)
423 : count === 3
424 ? Deferred.await(releaseLaterLookup)
425 : Effect.void,
426 ),
427 Effect.andThen(Ref.get(state)),
428 Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })),
429 ),
430 });
431
432 yield* Effect.gen(function* () {
433 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
434 yield* reactor.start();
435 yield* Deferred.succeed(fixture.activation, undefined);
436 yield* Queue.take(fixture.snapshotReads);
437 yield* Deferred.await(firstLookupStarted);
438
439 yield* fixture.updateSettings({ sidebarAutoSettleOnMerge: false });
440 yield* Deferred.succeed(releaseFirstLookup, undefined);
441 yield* Queue.take(fixture.snapshotReads);
442 yield* reactor.drain;
443 assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
444 assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 2);
445
446 yield* Ref.set(state, "closed");
447 yield* fixture.updateSettings({ enableAgentBrowserAccess: false });
448 yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 1 });
449 yield* Deferred.await(laterLookupStarted);
450 yield* Deferred.succeed(releaseLaterLookup, undefined);
451 yield* reactor.drain;
452
453 assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 3);
454 assert.strictEqual(yield* Ref.get(lookupCount), 3);
455 assert.deepStrictEqual(
456 (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
457 [ThreadId.make("settings-thread")],
458 );
459 }).pipe(Effect.provide(fixture.layer));
460 }),
461 ),
462 );
463
464 it.effect("keeps an unknown pull request active and continues with other candidates", () =>
465 Effect.scoped(
466 Effect.gen(function* () {
467 yield* TestClock.setTime(Date.parse(NOW));
468 const fixture = yield* makeHarness({
469 snapshot: makeSnapshot(
470 [
471 makeThread("lookup-failed", {
472 linkedPullRequest: {
473 projectId: LINKED_PROJECT_ID,
474 repository: "owner/repository",
475 number: 9,
476 url: "https://example.test/owner/repository/pull/9",
477 },
478 }),
479 makeThread("inactive-without-pr"),
480 ],
481 [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
482 ),
483 pullRequestDetail: () =>
484 Effect.fail(
485 new PullRequestOperationError({
486 operation: "detail",
487 detail: "host unavailable",
488 }),
489 ),
490 });
491
492 yield* Effect.gen(function* () {
493 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
494 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
495
496 assert.deepStrictEqual(
497 (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
498 [ThreadId.make("inactive-without-pr")],
499 );
500 assert.strictEqual((yield* Ref.get(fixture.detailCalls)).length, 1);
501 }).pipe(Effect.provide(fixture.layer));
502 }),
503 ),
504 );
505
506 it.effect("keeps threads active when their pull request project is unavailable", () =>
507 Effect.scoped(
508 Effect.gen(function* () {
509 yield* TestClock.setTime(Date.parse(NOW));
510 const linkedPullRequest = {
511 projectId: LINKED_PROJECT_ID,
512 repository: "owner/repository",
513 number: 10,
514 url: "https://example.test/owner/repository/pull/10",
515 } as const;
516 const fixture = yield* makeHarness({
517 snapshot: makeSnapshot(
518 [
519 makeThread("missing-own-project", { linkedPullRequest }),
520 makeThread("missing-branch-project", { branch: "saved-feature" }),
521 ],
522 [makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
523 ),
524 pullRequestDetail: (input) =>
525 Effect.succeed(makePullRequestDetail({ ...input, state: "open" })),
526 });
527
528 yield* Effect.gen(function* () {
529 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
530 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
531
532 assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
533 assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [
534 { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 },
535 ]);
536 assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []);
537 }).pipe(Effect.provide(fixture.layer));
538 }),
539 ),
540 );
541
542 it.effect("deduplicates saved-branch and linked pull request lookups within a sweep", () =>
543 Effect.scoped(
544 Effect.gen(function* () {
545 yield* TestClock.setTime(Date.parse(NOW));
546 const linkedPullRequest = {
547 projectId: LINKED_PROJECT_ID,
548 repository: "owner/repository",
549 number: 77,
550 url: "https://example.test/owner/repository/pull/77",
551 } as const;
552 const fixture = yield* makeHarness({
553 snapshot: makeSnapshot(
554 [
555 makeThread("branch-one", {
556 branch: "saved-feature",
557 worktreePath: "/deleted/worktree-one",
558 }),
559 makeThread("branch-two", {
560 branch: "saved-feature",
561 worktreePath: "/deleted/worktree-two",
562 }),
563 makeThread("linked-one", { linkedPullRequest }),
564 makeThread("linked-two", { linkedPullRequest }),
565 ],
566 [
567 makeProject(PROJECT_ID, "/workspace/project-root"),
568 makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"),
569 ],
570 ),
571 branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }),
572 pullRequestDetail: (input) =>
573 Effect.succeed(makePullRequestDetail({ ...input, state: "merged" })),
574 });
575
576 yield* Effect.gen(function* () {
577 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
578 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
579
580 assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [
581 { cwd: "/workspace/project-root", branch: "saved-feature" },
582 ]);
583 assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [
584 { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 },
585 ]);
586 assert.deepStrictEqual(
587 new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)),
588 new Set([
589 ThreadId.make("branch-one"),
590 ThreadId.make("branch-two"),
591 ThreadId.make("linked-one"),
592 ThreadId.make("linked-two"),
593 ]),
594 );
595 }).pipe(Effect.provide(fixture.layer));
596 }),
597 ),
598 );
599
600 it.effect("carries the snapshot guard and survives a stale dispatch rejection", () =>
601 Effect.scoped(
602 Effect.gen(function* () {
603 yield* TestClock.setTime(Date.parse(NOW));
604 const fixture = yield* makeHarness({
605 snapshot: makeSnapshot([makeThread("stale"), makeThread("next-candidate")]),
606 onDispatch: (command) =>
607 command.threadId === ThreadId.make("stale")
608 ? Effect.fail(
609 new OrchestrationCommandInvariantError({
610 commandType: command.type,
611 detail: "thread changed after settlement evaluation",
612 }),
613 )
614 : Effect.void,
615 });
616
617 yield* Effect.gen(function* () {
618 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
619 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
620
621 const firstSweep = yield* Ref.get(fixture.commands);
622 assert.strictEqual(
623 firstSweep.find((command) => command.threadId === ThreadId.make("stale"))
624 ?.snapshotSequence,
625 1,
626 );
627 assert.strictEqual(
628 firstSweep.some((command) => command.threadId === ThreadId.make("next-candidate")),
629 true,
630 );
631
632 yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 });
633 yield* Queue.take(fixture.snapshotReads);
634 yield* reactor.drain;
635 assert.strictEqual((yield* Ref.get(fixture.commands)).length, 4);
636 }).pipe(Effect.provide(fixture.layer));
637 }),
638 ),
639 );
640});
apps/server/src/orchestration/ThreadSettlementReactor.ts created+185
...@@ -0,0 +1,185 @@
1import { CommandId } from "@t3tools/contracts";
2import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
3import * as Cause from "effect/Cause";
4import * as Context from "effect/Context";
5import * as Crypto from "effect/Crypto";
6import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Schedule from "effect/Schedule";
10import type * as Scope from "effect/Scope";
11import * as Stream from "effect/Stream";
12
13import * as GitManager from "../git/GitManager.ts";
14import * as PullRequestService from "../pullRequest/PullRequestService.ts";
15import * as ServerSettings from "../serverSettings.ts";
16import { forkParked } from "../serverActivation.ts";
17import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
18import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";
19import {
20 isAutoSettlementCandidate,
21 shouldAutoSettleThread,
22 type SettlementPullRequest,
23} from "./ThreadSettlementPolicy.ts";
24
25export class ThreadSettlementReactor extends Context.Service<
26 ThreadSettlementReactor,
27 {
28 readonly start: () => Effect.Effect<void, never, Scope.Scope>;
29 readonly drain: Effect.Effect<void>;
30 }
31>()("t3/orchestration/ThreadSettlementReactor") {}
32
33export const make = Effect.gen(function* () {
34 const engine = yield* OrchestrationEngine.OrchestrationEngineService;
35 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
36 const settingsService = yield* ServerSettings.ServerSettingsService;
37 const git = yield* GitManager.GitManager;
38 const pullRequests = yield* PullRequestService.PullRequestService;
39 const crypto = yield* Crypto.Crypto;
40
41 const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* () {
42 const snapshot = yield* snapshots.getShellSnapshot();
43 const now = DateTime.formatIso(yield* DateTime.now);
44 const projects = new Map(snapshot.projects.map((project) => [project.id, project]));
45 const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now));
46 const lookupKey = (thread: (typeof candidates)[number]) => {
47 if (thread.linkedPullRequest != null) {
48 return JSON.stringify([
49 "linked",
50 thread.linkedPullRequest.projectId,
51 thread.linkedPullRequest.repository,
52 thread.linkedPullRequest.number,
53 ]);
54 }
55 if (thread.branch === null) return JSON.stringify(["none", thread.id]);
56 const project = projects.get(thread.projectId);
57 return JSON.stringify(
58 project === undefined
59 ? ["missing-project", thread.id]
60 : ["branch", project.workspaceRoot, thread.branch],
61 );
62 };
63 const groups = Map.groupBy(candidates, lookupKey);
64
65 const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* (
66 thread: (typeof candidates)[number],
67 ) {
68 if (thread.linkedPullRequest != null) {
69 if (!projects.has(thread.linkedPullRequest.projectId)) {
70 return yield* Effect.die(new Error("linked pull request project not found"));
71 }
72 const detail = yield* pullRequests.detail({
73 projectId: thread.linkedPullRequest.projectId,
74 repository: thread.linkedPullRequest.repository,
75 number: thread.linkedPullRequest.number,
76 });
77 return { state: detail.state, updatedAt: detail.updatedAt } satisfies SettlementPullRequest;
78 }
79 if (thread.branch === null) return null;
80 const project = projects.get(thread.projectId);
81 if (project === undefined) {
82 return yield* Effect.die(new Error("thread project not found"));
83 }
84 return yield* git.branchPullRequest({ cwd: project.workspaceRoot, branch: thread.branch });
85 });
86
87 yield* Effect.forEach(
88 groups.values(),
89 (group) =>
90 Effect.gen(function* () {
91 const pullRequest = yield* pullRequestFor(group[0]!);
92 yield* Effect.forEach(
93 group,
94 (thread) =>
95 Effect.gen(function* () {
96 const settings = yield* settingsService.getSettings;
97 const decisionNow = DateTime.formatIso(yield* DateTime.now);
98 if (
99 !shouldAutoSettleThread({
100 thread,
101 pullRequest,
102 now: decisionNow,
103 autoSettleAfterDays: settings.sidebarAutoSettleAfterDays,
104 autoSettleOnMerge: settings.sidebarAutoSettleOnMerge,
105 })
106 ) {
107 return;
108 }
109 const uuid = yield* crypto.randomUUIDv4;
110 yield* engine.dispatch({
111 type: "thread.auto-settle",
112 commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`),
113 threadId: thread.id,
114 snapshotSequence: snapshot.snapshotSequence,
115 });
116 }).pipe(
117 Effect.catchCause((cause) =>
118 Cause.hasInterruptsOnly(cause)
119 ? Effect.failCause(cause)
120 : Effect.logWarning("automatic thread settlement skipped", {
121 threadId: thread.id,
122 cause: Cause.pretty(cause),
123 }),
124 ),
125 ),
126 { discard: true },
127 );
128 }).pipe(
129 Effect.catchCause((cause) =>
130 Cause.hasInterruptsOnly(cause)
131 ? Effect.failCause(cause)
132 : Effect.logWarning("automatic thread settlement skipped", {
133 threadIds: group.map((thread) => thread.id),
134 cause: Cause.pretty(cause),
135 }),
136 ),
137 ),
138 { concurrency: 8, discard: true },
139 );
140 });
141
142 const worker = yield* makeDrainableWorker(() =>
143 sweep().pipe(
144 Effect.catchCause((cause) =>
145 Cause.hasInterruptsOnly(cause)
146 ? Effect.failCause(cause)
147 : Effect.logWarning("automatic thread settlement sweep failed", {
148 cause: Cause.pretty(cause),
149 }),
150 ),
151 ),
152 );
153
154 const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn(
155 "ThreadSettlementReactor.start",
156 )(function* () {
157 const settingsChanges = yield* settingsService.subscribeChanges;
158 const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie);
159 let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays;
160 let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge;
161 yield* forkParked(
162 Effect.gen(function* () {
163 yield* worker.enqueue(undefined);
164 yield* worker.drain;
165 }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid),
166 );
167 yield* forkParked(
168 Stream.runForEach(settingsChanges, (settings) => {
169 if (
170 settings.sidebarAutoSettleAfterDays === lastAfterDays &&
171 settings.sidebarAutoSettleOnMerge === lastOnMerge
172 ) {
173 return Effect.void;
174 }
175 lastAfterDays = settings.sidebarAutoSettleAfterDays;
176 lastOnMerge = settings.sidebarAutoSettleOnMerge;
177 return worker.enqueue(undefined);
178 }),
179 );
180 });
181
182 return { start, drain: worker.drain } satisfies ThreadSettlementReactor["Service"];
183});
184
185export const layer = Layer.effect(ThreadSettlementReactor, make);
apps/server/src/orchestration/decider.settled.test.ts+44-7
...@@ -19,6 +19,8 @@ import { projectEvent } from "./projector.ts";...@@ -19,6 +19,8 @@ import { projectEvent } from "./projector.ts";
1919
20const NOW = "2026-01-01T00:00:00.000Z";20const NOW = "2026-01-01T00:00:00.000Z";
21const SETTLED_AT = "2025-12-30T00:00:00.000Z";21const SETTLED_AT = "2025-12-30T00:00:00.000Z";
22const SETTLE_BLOCKED_MESSAGE =
23 "This thread still needs attention. Resolve or interrupt it first, then try again.";
2224
23function makeReadModel(25function makeReadModel(
24 settledOverride: OrchestrationThread["settledOverride"],26 settledOverride: OrchestrationThread["settledOverride"],
...@@ -79,6 +81,22 @@ function makeSession(status: OrchestrationSession["status"]): OrchestrationSessi...@@ -79,6 +81,22 @@ function makeSession(status: OrchestrationSession["status"]): OrchestrationSessi
79}81}
8082
81it.layer(NodeServices.layer)("settled thread decider", (it) => {83it.layer(NodeServices.layer)("settled thread decider", (it) => {
84 it.effect("rejects an automatic settle when the thread is pinned active", () =>
85 Effect.gen(function* () {
86 const command = {
87 type: "thread.auto-settle" as const,
88 commandId: CommandId.make("cmd-auto-settle"),
89 threadId: ThreadId.make("thread-1"),
90 snapshotSequence: 0,
91 };
92 const pinnedActive = yield* decideOrchestrationCommand({
93 command,
94 readModel: makeReadModel("active"),
95 }).pipe(Effect.flip);
96 expect(pinnedActive._tag).toBe("OrchestrationCommandInvariantError");
97 }),
98 );
99
82 it.effect("settles awake threads without a redundant wake and re-emits idempotently", () =>100 it.effect("settles awake threads without a redundant wake and re-emits idempotently", () =>
83 Effect.gen(function* () {101 Effect.gen(function* () {
84 const event = yield* decideOrchestrationCommand({102 const event = yield* decideOrchestrationCommand({
...@@ -198,7 +216,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {...@@ -198,7 +216,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
198 },216 },
199 readModel: makeReadModel(null, null, makeSession(status)),217 readModel: makeReadModel(null, null, makeSession(status)),
200 }).pipe(Effect.flip);218 }).pipe(Effect.flip);
201 expect(error._tag).toBe("OrchestrationCommandInvariantError");219 expect(error).toMatchObject({
220 _tag: "OrchestrationThreadSettleBlockedError",
221 threadId: ThreadId.make("thread-1"),
222 message: SETTLE_BLOCKED_MESSAGE,
223 });
202 }224 }
203 // Stopped/error sessions are settleable — only live work is protected.225 // Stopped/error sessions are settleable — only live work is protected.
204 const settled = yield* decideOrchestrationCommand({226 const settled = yield* decideOrchestrationCommand({
...@@ -238,7 +260,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {...@@ -238,7 +260,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
238 requestActivity("approval.requested", "req-1", NOW),260 requestActivity("approval.requested", "req-1", NOW),
239 ]),261 ]),
240 }).pipe(Effect.flip);262 }).pipe(Effect.flip);
241 expect(openError._tag).toBe("OrchestrationCommandInvariantError");263 expect(openError).toMatchObject({
264 _tag: "OrchestrationThreadSettleBlockedError",
265 threadId: ThreadId.make("thread-1"),
266 message: SETTLE_BLOCKED_MESSAGE,
267 });
242268
243 // Same request later resolved: settleable again.269 // Same request later resolved: settleable again.
244 const settled = yield* decideOrchestrationCommand({270 const settled = yield* decideOrchestrationCommand({
...@@ -266,7 +292,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {...@@ -266,7 +292,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
266 requestActivity("user-input.requested", "req-2", NOW),292 requestActivity("user-input.requested", "req-2", NOW),
267 ]),293 ]),
268 }).pipe(Effect.flip);294 }).pipe(Effect.flip);
269 expect(inputError._tag).toBe("OrchestrationCommandInvariantError");295 expect(inputError).toMatchObject({
296 _tag: "OrchestrationThreadSettleBlockedError",
297 threadId: ThreadId.make("thread-1"),
298 message: SETTLE_BLOCKED_MESSAGE,
299 });
270 }),300 }),
271 );301 );
272302
...@@ -287,8 +317,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {...@@ -287,8 +317,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
287 createdAt: NOW,317 createdAt: NOW,
288 }) as OrchestrationThread["activities"][number];318 }) as OrchestrationThread["activities"][number];
289319
290 // Stale-failure detail clears the request — mirrors the projection's320 // Stale-failure details clear the request, matching the projection flags.
291 // pending accounting, which is what the client's canSettle sees.
292 const settled = yield* decideOrchestrationCommand({321 const settled = yield* decideOrchestrationCommand({
293 command: {322 command: {
294 type: "thread.settle",323 type: "thread.settle",
...@@ -324,7 +353,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {...@@ -324,7 +353,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
324 }),353 }),
325 ]),354 ]),
326 }).pipe(Effect.flip);355 }).pipe(Effect.flip);
327 expect(stillOpen._tag).toBe("OrchestrationCommandInvariantError");356 expect(stillOpen).toMatchObject({
357 _tag: "OrchestrationThreadSettleBlockedError",
358 threadId: ThreadId.make("thread-1"),
359 message: SETTLE_BLOCKED_MESSAGE,
360 });
328 }),361 }),
329 );362 );
330363
...@@ -352,7 +385,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {...@@ -352,7 +385,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
352 },385 },
353 readModel: makeReadModel(null, null, null, [], [userMessage("1969-12-31T23:59:30.000Z")]),386 readModel: makeReadModel(null, null, null, [], [userMessage("1969-12-31T23:59:30.000Z")]),
354 }).pipe(Effect.flip);387 }).pipe(Effect.flip);
355 expect(queuedError._tag).toBe("OrchestrationCommandInvariantError");388 expect(queuedError).toMatchObject({
389 _tag: "OrchestrationThreadSettleBlockedError",
390 threadId: ThreadId.make("thread-1"),
391 message: SETTLE_BLOCKED_MESSAGE,
392 });
356393
357 // Message timestamp far in the FUTURE (client clock ahead of server):394 // Message timestamp far in the FUTURE (client clock ahead of server):
358 // a negative age must not read as queued forever — past the grace395 // a negative age must not read as queued forever — past the grace
apps/server/src/orchestration/decider.ts+44-81
...@@ -3,13 +3,18 @@ import {...@@ -3,13 +3,18 @@ import {
3 type OrchestrationCommand,3 type OrchestrationCommand,
4 type OrchestrationEvent,4 type OrchestrationEvent,
5 type OrchestrationReadModel,5 type OrchestrationReadModel,
6 type OrchestrationThread,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
7import * as DateTime from "effect/DateTime";8import * as DateTime from "effect/DateTime";
8import * as Crypto from "effect/Crypto";9import * as Crypto from "effect/Crypto";
9import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
10import type * as PlatformError from "effect/PlatformError";11import type * as PlatformError from "effect/PlatformError";
1112
12import { OrchestrationCommandInvariantError } from "./Errors.ts";13import {
14 OrchestrationCommandInvariantError,
15 OrchestrationThreadSettleBlockedError,
16 type OrchestrationCommandRejection,
17} from "./Errors.ts";
13import {18import {
14 listThreadsByProjectId,19 listThreadsByProjectId,
15 requireActiveProjectWorkspaceRootAbsent,20 requireActiveProjectWorkspaceRootAbsent,
...@@ -21,14 +26,10 @@ import {...@@ -21,14 +26,10 @@ import {
21 requireThreadNotArchived,26 requireThreadNotArchived,
22} from "./commandInvariants.ts";27} from "./commandInvariants.ts";
23import { projectEvent } from "./projector.ts";28import { projectEvent } from "./projector.ts";
29import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts";
2430
25const nowIso = Effect.map(DateTime.now, DateTime.formatIso);31const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
2632
27// Session adoption takes seconds; a user message still unadopted after this
28// window is a failed/stale start, not pending work. Mirrors the client's
29// QUEUED_TURN_START_GRACE_MS in client-runtime threadSettled.ts.
30const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;
31
32/**33/**
33 * Blocked-on-you work derived from the thread's retained activities: an34 * Blocked-on-you work derived from the thread's retained activities: an
34 * approval or user-input request with no later resolution for the same35 * approval or user-input request with no later resolution for the same
...@@ -86,59 +87,28 @@ function hasOpenBlockingRequest(thread: {...@@ -86,59 +87,28 @@ function hasOpenBlockingRequest(thread: {
86 return openRequestIds.size > 0;87 return openRequestIds.size > 0;
87}88}
8889
89/**90/** Apply the shared shell-level rule to the detailed command read model. */
90 * A queued turn start — a user message no turn has picked up yet — is work91function hasQueuedTurnStartForThread(
91 * in flight even though session is still null (turn.start emits92 thread: Pick<OrchestrationThread, "messages" | "latestTurn" | "session">,
92 * message-sent + turn-start-requested; the session arrives later). Detection93 now: string,
93 * mirrors the client's hasQueuedTurnStart: the newest user message is
94 * strictly newer than every latestTurn timestamp (adoption stamps the new
95 * turn's requestedAt with the message time, clearing this), and only within
96 * the adoption grace window — historical threads whose last user message
97 * postdates their turn timestamps (older-server data, mid-turn messages)
98 * must not be blocked forever. A failed session start (status "error")
99 * clears the block immediately.
100 *
101 * The age check is bounded on BOTH sides: message timestamps are
102 * client-supplied, so a client clock ahead of the server yields a negative
103 * age. Without the lower bound that negative age satisfies `<= grace` for
104 * as long as the skew lasts, extending the block far past the intended two
105 * minutes.
106 */
107function threadHasQueuedTurnStart(
108 thread: {
109 readonly messages: ReadonlyArray<{ readonly role: string; readonly createdAt: string }>;
110 readonly latestTurn: {
111 readonly requestedAt: string;
112 readonly startedAt: string | null;
113 readonly completedAt: string | null;
114 } | null;
115 readonly session: { readonly status: string } | null;
116 },
117 occurredAt: string,
118): boolean {94): boolean {
119 const latestUserMessageAtMs = thread.messages.reduce(95 let latestUserMessageAt: string | null = null;
120 (latest, message) =>96 let latestUserMessageAtMs = Number.NEGATIVE_INFINITY;
121 message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest,97 for (const message of thread.messages) {
122 Number.NEGATIVE_INFINITY,98 if (message.role !== "user") continue;
123 );99 const messageAtMs = Date.parse(message.createdAt);
124 const latestTurnAtMs =100 latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs);
125 thread.latestTurn === null101 if (messageAtMs === latestUserMessageAtMs) {
126 ? Number.NEGATIVE_INFINITY102 latestUserMessageAt = message.createdAt;
127 : Math.max(103 }
128 ...[104 }
129 thread.latestTurn.requestedAt,105 return threadHasQueuedTurnStart(
130 thread.latestTurn.startedAt,106 {
131 thread.latestTurn.completedAt,107 latestUserMessageAt: Number.isFinite(latestUserMessageAtMs) ? latestUserMessageAt : null,
132 ].map((candidate) =>108 latestTurn: thread.latestTurn,
133 candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate),109 session: thread.session,
134 ),110 },
135 );111 now,
136 const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs;
137 return (
138 thread.session?.status !== "error" &&
139 Number.isFinite(latestUserMessageAtMs) &&
140 latestUserMessageAtMs > latestTurnAtMs &&
141 Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS
142 );112 );
143}113}
144114
...@@ -186,7 +156,7 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({...@@ -186,7 +156,7 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({
186 readonly readModel: OrchestrationReadModel;156 readonly readModel: OrchestrationReadModel;
187}): Effect.fn.Return<157}): Effect.fn.Return<
188 ReadonlyArray<PlannedOrchestrationEvent>,158 ReadonlyArray<PlannedOrchestrationEvent>,
189 OrchestrationCommandInvariantError | PlatformError.PlatformError,159 OrchestrationCommandRejection | PlatformError.PlatformError,
190 Crypto.Crypto160 Crypto.Crypto
191> {161> {
192 let nextReadModel = readModel;162 let nextReadModel = readModel;
...@@ -220,7 +190,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -220,7 +190,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
220 readonly readModel: OrchestrationReadModel;190 readonly readModel: OrchestrationReadModel;
221}): Effect.fn.Return<191}): Effect.fn.Return<
222 DecideOrchestrationCommandResult,192 DecideOrchestrationCommandResult,
223 OrchestrationCommandInvariantError | PlatformError.PlatformError,193 OrchestrationCommandRejection | PlatformError.PlatformError,
224 Crypto.Crypto194 Crypto.Crypto
225> {195> {
226 switch (command.type) {196 switch (command.type) {
...@@ -450,43 +420,36 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -450,43 +420,36 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
450 };420 };
451 }421 }
452422
453 case "thread.settle": {423 case "thread.settle":
424 case "thread.auto-settle": {
454 const thread = yield* requireThreadNotArchived({425 const thread = yield* requireThreadNotArchived({
455 readModel,426 readModel,
456 command,427 command,
457 threadId: command.threadId,428 threadId: command.threadId,
458 });429 });
459 // Server-side twin of the client's canSettle session check: a stale430 if (command.type === "thread.auto-settle" && thread.settledOverride !== null) {
460 // or raced client must not settle a thread whose session is coming
461 // alive or working.
462 if (thread.session?.status === "starting" || thread.session?.status === "running") {
463 return yield* Effect.fail(431 return yield* Effect.fail(
464 new OrchestrationCommandInvariantError({432 new OrchestrationCommandInvariantError({
465 commandType: command.type,433 commandType: command.type,
466 detail: `thread ${command.threadId} has an active session and cannot be settled`,434 detail: `thread ${command.threadId} changed before automatic settlement`,
467 }),435 }),
468 );436 );
469 }437 }
438 // The server owns settle eligibility. A stale command must not settle
439 // a thread whose session is coming alive or working.
440 if (thread.session?.status === "starting" || thread.session?.status === "running") {
441 return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId });
442 }
470 // Pending approval / user-input requests are blocked-on-you work: a443 // Pending approval / user-input requests are blocked-on-you work: a
471 // raced or stale client must not park them behind a settled override444 // raced or stale client must not park them behind a settled override
472 // that would surface only after the request resolves.445 // that would surface only after the request resolves.
473 if (hasOpenBlockingRequest(thread)) {446 if (hasOpenBlockingRequest(thread)) {
474 return yield* Effect.fail(447 return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId });
475 new OrchestrationCommandInvariantError({
476 commandType: command.type,
477 detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be settled`,
478 }),
479 );
480 }448 }
481 const occurredAt = yield* nowIso;449 const occurredAt = yield* nowIso;
482 // Settling inside the adoption window would hide just-requested work.450 // Settling inside the adoption window would hide just-requested work.
483 if (threadHasQueuedTurnStart(thread, occurredAt)) {451 if (hasQueuedTurnStartForThread(thread, occurredAt)) {
484 return yield* Effect.fail(452 return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId });
485 new OrchestrationCommandInvariantError({
486 commandType: command.type,
487 detail: `thread ${command.threadId} has a queued turn start and cannot be settled`,
488 }),
489 );
490 }453 }
491 // Settling an already-settled thread re-emits with the original454 // Settling an already-settled thread re-emits with the original
492 // settledAt: the engine rejects zero-event commands, and bulk-settle /455 // settledAt: the engine rejects zero-event commands, and bulk-settle /
...@@ -610,7 +573,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -610,7 +573,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
610 // invisible pending work: no session, no pending flags. Snoozing in573 // invisible pending work: no session, no pending flags. Snoozing in
611 // that window would hide a just-requested turn exactly the way settle574 // that window would hide a just-requested turn exactly the way settle
612 // would.575 // would.
613 if (threadHasQueuedTurnStart(thread, occurredAt)) {576 if (hasQueuedTurnStartForThread(thread, occurredAt)) {
614 return yield* Effect.fail(577 return yield* Effect.fail(
615 new OrchestrationCommandInvariantError({578 new OrchestrationCommandInvariantError({
616 commandType: command.type,579 commandType: command.type,
...@@ -1152,7 +1115,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -1152,7 +1115,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
1152 if (1115 if (
1153 thread.settledOverride !== "settled" ||1116 thread.settledOverride !== "settled" ||
1154 sessionComingAlive ||1117 sessionComingAlive ||
1155 threadHasQueuedTurnStart(thread, command.createdAt)1118 hasQueuedTurnStartForThread(thread, command.createdAt)
1156 ) {1119 ) {
1157 return yield* Effect.fail(1120 return yield* Effect.fail(
1158 new OrchestrationCommandInvariantError({1121 new OrchestrationCommandInvariantError({
apps/server/src/persistence/Layers/OrchestrationEventStore.ts+12-11
...@@ -64,7 +64,7 @@ const OrchestrationEventPersistedRowSchema = Schema.Struct({...@@ -64,7 +64,7 @@ const OrchestrationEventPersistedRowSchema = Schema.Struct({
64const HasEventAfterRequestSchema = Schema.Struct({64const HasEventAfterRequestSchema = Schema.Struct({
65 aggregateKind: Schema.String,65 aggregateKind: Schema.String,
66 aggregateId: Schema.String,66 aggregateId: Schema.String,
67 type: Schema.String,67 type: Schema.optional(Schema.String),
68 sequenceExclusive: NonNegativeInt,68 sequenceExclusive: NonNegativeInt,
69});69});
7070
...@@ -271,16 +271,17 @@ const makeEventStore = Effect.gen(function* () {...@@ -271,16 +271,17 @@ const makeEventStore = Effect.gen(function* () {
271 const findEventAfter = SqlSchema.findOneOption({271 const findEventAfter = SqlSchema.findOneOption({
272 Request: HasEventAfterRequestSchema,272 Request: HasEventAfterRequestSchema,
273 Result: Schema.Struct({ sequence: Schema.Number }),273 Result: Schema.Struct({ sequence: Schema.Number }),
274 execute: (request) =>274 execute: (request) => sql`
275 sql`275 SELECT sequence
276 SELECT sequence276 FROM orchestration_events
277 FROM orchestration_events277 WHERE aggregate_kind = ${request.aggregateKind}
278 WHERE aggregate_kind = ${request.aggregateKind}278 AND stream_id = ${request.aggregateId}
279 AND stream_id = ${request.aggregateId}279 AND ${sql.and([
280 AND event_type = ${request.type}280 sql`sequence > ${request.sequenceExclusive}`,
281 AND sequence > ${request.sequenceExclusive}281 ...(request.type === undefined ? [] : [sql`event_type = ${request.type}`]),
282 LIMIT 1282 ])}
283 `,283 LIMIT 1
284 `,
284 });285 });
285286
286 const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) =>287 const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) =>
apps/server/src/persistence/Services/OrchestrationEventStore.ts+3-2
...@@ -54,7 +54,8 @@ export interface OrchestrationEventStoreShape {...@@ -54,7 +54,8 @@ export interface OrchestrationEventStoreShape {
54 readonly readAll: () => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError>;54 readonly readAll: () => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError>;
5555
56 /**56 /**
57 * Check whether an aggregate has an event of the given type after a sequence.57 * Check whether an aggregate has an event after a sequence, optionally
58 * restricted to one event type.
58 *59 *
59 * Used during replay to tell whether a later event supersedes the one being60 * Used during replay to tell whether a later event supersedes the one being
60 * applied, without streaming the rest of the log.61 * applied, without streaming the rest of the log.
...@@ -62,7 +63,7 @@ export interface OrchestrationEventStoreShape {...@@ -62,7 +63,7 @@ export interface OrchestrationEventStoreShape {
62 readonly hasEventAfter: (input: {63 readonly hasEventAfter: (input: {
63 readonly aggregateKind: OrchestrationEvent["aggregateKind"];64 readonly aggregateKind: OrchestrationEvent["aggregateKind"];
64 readonly aggregateId: string;65 readonly aggregateId: string;
65 readonly type: OrchestrationEvent["type"];66 readonly type?: OrchestrationEvent["type"];
66 readonly sequenceExclusive: number;67 readonly sequenceExclusive: number;
67 }) => Effect.Effect<boolean, OrchestrationEventStoreError>;68 }) => Effect.Effect<boolean, OrchestrationEventStoreError>;
68}69}
apps/server/src/server.test.ts+20-41
...@@ -118,7 +118,10 @@ import * as Keybindings from "./keybindings.ts";...@@ -118,7 +118,10 @@ import * as Keybindings from "./keybindings.ts";
118import * as ExternalLauncher from "./process/externalLauncher.ts";118import * as ExternalLauncher from "./process/externalLauncher.ts";
119import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";119import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
120import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";120import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
121import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts";121import {
122 OrchestrationListenerCallbackError,
123 OrchestrationThreadSettleBlockedError,
124} from "./orchestration/Errors.ts";
122import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";125import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
123import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts";126import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts";
124import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";127import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
...@@ -8036,7 +8039,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -8036,7 +8039,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
8036 }).pipe(Effect.provide(NodeHttpServer.layerTest)),8039 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
8037 );8040 );
80388041
8039 it.effect("stops the provider session after settle without closing terminals", () =>8042 it.effect("leaves settle cleanup to the event reactor", () =>
8040 Effect.gen(function* () {8043 Effect.gen(function* () {
8041 const threadId = ThreadId.make("thread-settle");8044 const threadId = ThreadId.make("thread-settle");
8042 const effects: string[] = [];8045 const effects: string[] = [];
...@@ -8094,64 +8097,40 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -8094,64 +8097,40 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
8094 );8097 );
80958098
8096 assert.equal(dispatchResult.sequence, 1);8099 assert.equal(dispatchResult.sequence, 1);
8097 assert.deepEqual(effects, ["dispatch:thread.settle", "dispatch:thread.session.stop"]);8100 assert.deepEqual(effects, ["dispatch:thread.settle"]);
8098 const sessionStopCommand = dispatchedCommands[1];8101 assert.deepEqual(
8099 assert.equal(sessionStopCommand?.type, "thread.session.stop");8102 dispatchedCommands.map((command) => command.type),
8100 if (sessionStopCommand?.type === "thread.session.stop") {8103 ["thread.settle"],
8101 assert.equal(sessionStopCommand.threadId, threadId);8104 );
8102 assert.equal(sessionStopCommand.commandId, "session-stop-for-settle:cmd-thread-settle");
8103 assert.equal(sessionStopCommand.onlyIfSettled, true);
8104 }
8105 }).pipe(Effect.provide(NodeHttpServer.layerTest)),8105 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
8106 );8106 );
81078107
8108 it.effect("settles without dispatching session stop when the thread has no session", () =>8108 it.effect("forwards the friendly blocked-settlement message over websocket rpc", () =>
8109 Effect.gen(function* () {8109 Effect.gen(function* () {
8110 const threadId = ThreadId.make("thread-settle-no-session");8110 const threadId = ThreadId.make("thread-settle-blocked");
8111 const effects: string[] = [];
8112 const dispatchedCommands: Array<OrchestrationCommand> = [];
8113
8114 yield* buildAppUnderTest({8111 yield* buildAppUnderTest({
8115 layers: {8112 layers: {
8116 terminalManager: {
8117 close: (input) =>
8118 Effect.sync(() => {
8119 effects.push(`terminal.close:${input.threadId}`);
8120 }),
8121 },
8122 orchestrationEngine: {8113 orchestrationEngine: {
8123 dispatch: (command) =>8114 dispatch: () => Effect.fail(new OrchestrationThreadSettleBlockedError({ threadId })),
8124 Effect.sync(() => {
8125 dispatchedCommands.push(command);
8126 effects.push(`dispatch:${command.type}`);
8127 return { sequence: dispatchedCommands.length };
8128 }),
8129 },
8130 projectionSnapshotQuery: {
8131 getThreadShellById: () =>
8132 Effect.succeed(
8133 Option.some(makeDefaultOrchestrationThreadShell({ id: threadId, session: null })),
8134 ),
8135 },8115 },
8136 },8116 },
8137 });8117 });
81388118
8139 const wsUrl = yield* getWsServerUrl("/ws");8119 const wsUrl = yield* getWsServerUrl("/ws");
8140 const dispatchResult = yield* Effect.scoped(8120 const error = yield* Effect.scoped(
8141 withWsRpcClient(wsUrl, (client) =>8121 withWsRpcClient(wsUrl, (client) =>
8142 client[ORCHESTRATION_WS_METHODS.dispatchCommand]({8122 client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
8143 type: "thread.settle",8123 type: "thread.settle",
8144 commandId: CommandId.make("cmd-thread-settle-no-session"),8124 commandId: CommandId.make("cmd-thread-settle-blocked"),
8145 threadId,8125 threadId,
8146 }),8126 }),
8147 ),8127 ).pipe(Effect.flip),
8148 );8128 );
81498129
8150 assert.equal(dispatchResult.sequence, 1);8130 assert.equal(error._tag, "OrchestrationDispatchCommandError");
8151 assert.deepEqual(effects, ["dispatch:thread.settle"]);8131 assert.equal(
8152 assert.deepEqual(8132 error.message,
8153 dispatchedCommands.map((command) => command.type),8133 "This thread still needs attention. Resolve or interrupt it first, then try again.",
8154 ["thread.settle"],
8155 );8134 );
8156 }).pipe(Effect.provide(NodeHttpServer.layerTest)),8135 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
8157 );8136 );
apps/server/src/server.ts+12-9
...@@ -62,6 +62,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun...@@ -62,6 +62,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun
62import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";62import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";
63import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";63import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
64import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";64import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
65import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts";
65import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";66import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
66import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";67import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
67import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";68import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
...@@ -257,6 +258,7 @@ const ReactorLayerLive = Layer.empty.pipe(...@@ -257,6 +258,7 @@ const ReactorLayerLive = Layer.empty.pipe(
257 Layer.provideMerge(ProviderCommandReactorLive),258 Layer.provideMerge(ProviderCommandReactorLive),
258 Layer.provideMerge(CheckpointReactorLive),259 Layer.provideMerge(CheckpointReactorLive),
259 Layer.provideMerge(ThreadDeletionReactorLive),260 Layer.provideMerge(ThreadDeletionReactorLive),
261 Layer.provideMerge(ThreadSettlementReactor.layer),
260 Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))),262 Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))),
261 Layer.provideMerge(RuntimeReceiptBusLive),263 Layer.provideMerge(RuntimeReceiptBusLive),
262);264);
...@@ -290,6 +292,13 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay...@@ -290,6 +292,13 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay
290 Layer.provideMerge(VcsDriverRegistryLayerLive),292 Layer.provideMerge(VcsDriverRegistryLayerLive),
291);293);
292294
295const PullRequestServiceLive = PullRequestService.layer.pipe(
296 Layer.provide(PullRequestProviderRegistry.layer),
297 Layer.provide(SourceControlProviderRegistryLayerLive),
298 Layer.provide(SourceControlRateLimit.layer),
299 Layer.provide(VcsProcess.layer),
300);
301
293const GitManagerLayerLive = GitManager.layer.pipe(302const GitManagerLayerLive = GitManager.layer.pipe(
294 Layer.provideMerge(ProjectSetupScriptRunner.layer),303 Layer.provideMerge(ProjectSetupScriptRunner.layer),
295 Layer.provideMerge(GitVcsDriver.layer),304 Layer.provideMerge(GitVcsDriver.layer),
...@@ -389,7 +398,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(...@@ -389,7 +398,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
389 // Core Services398 // Core Services
390 Layer.provideMerge(ServerSettingsLayerLive),399 Layer.provideMerge(ServerSettingsLayerLive),
391 Layer.provideMerge(CheckpointingLayerLive),400 Layer.provideMerge(CheckpointingLayerLive),
392 Layer.provideMerge(SourceControlProviderRegistryLayerLive),401 Layer.provideMerge(
402 Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLive),
403 ),
393 Layer.provideMerge(GitLayerLive),404 Layer.provideMerge(GitLayerLive),
394 Layer.provideMerge(VcsLayerLive),405 Layer.provideMerge(VcsLayerLive),
395 Layer.provideMerge(ProviderRuntimeLayerLive),406 Layer.provideMerge(ProviderRuntimeLayerLive),
...@@ -458,14 +469,6 @@ const commandReadinessLayer = HttpRouter.middleware(...@@ -458,14 +469,6 @@ const commandReadinessLayer = HttpRouter.middleware(
458 { global: true },469 { global: true },
459);470);
460471
461const PullRequestServiceLive = PullRequestService.layer.pipe(
462 // One registry entry per supported host; the service only knows the registry.
463 Layer.provide(PullRequestProviderRegistry.layer),
464 Layer.provide(SourceControlProviderRegistryLayerLive),
465 Layer.provide(SourceControlRateLimit.layer),
466 Layer.provide(VcsProcess.layer),
467);
468
469export const makeRoutesLayer = Layer.mergeAll(472export const makeRoutesLayer = Layer.mergeAll(
470 Layer.mergeAll(473 Layer.mergeAll(
471 HttpApiBuilder.layer(EnvironmentHttpApi).pipe(474 HttpApiBuilder.layer(EnvironmentHttpApi).pipe(
apps/server/src/serverSettings.test.ts+28
...@@ -272,6 +272,34 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -272,6 +272,34 @@ it.layer(NodeServices.layer)("server settings", (it) => {
272 ).pipe(Effect.provide(makeServerSettingsLayer())),272 ).pipe(Effect.provide(makeServerSettingsLayer())),
273 );273 );
274274
275 it.effect("persists and broadcasts thread settlement settings", () =>
276 Effect.scoped(
277 Effect.gen(function* () {
278 const serverConfig = yield* ServerConfig.ServerConfig;
279 const fileSystem = yield* FileSystem.FileSystem;
280 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
281 const changes = yield* serverSettings.subscribeChanges;
282
283 const next = yield* serverSettings.updateSettings({
284 sidebarAutoSettleAfterDays: null,
285 sidebarAutoSettleOnMerge: false,
286 });
287 const change = Option.getOrUndefined(yield* Stream.runHead(changes));
288 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
289 // Inspect raw persisted JSON before schema decoding can apply defaults.
290 // @effect-diagnostics-next-line preferSchemaOverJson:off
291 const persisted = JSON.parse(raw) as Record<string, unknown>;
292
293 assert.strictEqual(next.sidebarAutoSettleAfterDays, null);
294 assert.isFalse(next.sidebarAutoSettleOnMerge);
295 assert.strictEqual(change?.sidebarAutoSettleAfterDays, null);
296 assert.isFalse(change?.sidebarAutoSettleOnMerge);
297 assert.strictEqual(persisted.sidebarAutoSettleAfterDays, null);
298 assert.isFalse(persisted.sidebarAutoSettleOnMerge);
299 }),
300 ).pipe(Effect.provide(makeServerSettingsLayer())),
301 );
302
275 it.effect("preserves model when switching providers via textGenerationModelSelection", () =>303 it.effect("preserves model when switching providers via textGenerationModelSelection", () =>
276 Effect.gen(function* () {304 Effect.gen(function* () {
277 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;305 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
apps/server/src/ws.ts+25-42
...@@ -1223,23 +1223,17 @@ const makeWsRpcLayer = (...@@ -1223,23 +1223,17 @@ const makeWsRpcLayer = (
1223 ORCHESTRATION_WS_METHODS.dispatchCommand,1223 ORCHESTRATION_WS_METHODS.dispatchCommand,
1224 Effect.gen(function* () {1224 Effect.gen(function* () {
1225 const normalizedCommand = yield* normalizeDispatchCommand(command);1225 const normalizedCommand = yield* normalizeDispatchCommand(command);
1226 // Archive and settle both mean "done with this thread", so a1226 // Archive removes the thread from the client, so this transport
1227 // live provider session must not keep running background work1227 // closes its session and terminals after the command lands.
1228 // (PR monitors, dev servers, subagent fleets) after either1228 // Settlement cleanup is driven by thread.settled events in the
1229 // lands. The decider rejects settling a starting/running1229 // provider reactor, including settlements that have no client.
1230 // session, so for settle this only ever stops an idle one; a1230 const archiveCommand =
1231 // stopped session-set does not count as activity, so the stop1231 normalizedCommand.type === "thread.archive" ? normalizedCommand : undefined;
1232 // cannot un-settle the thread it follows.1232 // Best-effort on purpose: the user's archive must not
1233 const parkingCommand =
1234 normalizedCommand.type === "thread.archive" ||
1235 normalizedCommand.type === "thread.settle"
1236 ? normalizedCommand
1237 : undefined;
1238 // Best-effort on purpose: the user's archive/settle must not
1239 // fail because this cleanup read blipped, so a failed read1233 // fail because this cleanup read blipped, so a failed read
1240 // logs and skips the stop instead of propagating.1234 // logs and skips the stop instead of propagating.
1241 const shouldStopSessionAfterCommand = parkingCommand1235 const shouldStopSessionAfterCommand = archiveCommand
1242 ? yield* projectionSnapshotQuery.getThreadShellById(parkingCommand.threadId).pipe(1236 ? yield* projectionSnapshotQuery.getThreadShellById(archiveCommand.threadId).pipe(
1243 Effect.map(1237 Effect.map(
1244 Option.match({1238 Option.match({
1245 onNone: () => false,1239 onNone: () => false,
...@@ -1250,7 +1244,7 @@ const makeWsRpcLayer = (...@@ -1250,7 +1244,7 @@ const makeWsRpcLayer = (
1250 Effect.catchCause((cause) =>1244 Effect.catchCause((cause) =>
1251 Effect.logWarning(1245 Effect.logWarning(
1252 "failed to read thread session state before session-stop check",1246 "failed to read thread session state before session-stop check",
1253 { threadId: parkingCommand.threadId, cause },1247 { threadId: archiveCommand.threadId, cause },
1254 ).pipe(Effect.as(false)),1248 ).pipe(Effect.as(false)),
1255 ),1249 ),
1256 )1250 )
...@@ -1259,50 +1253,39 @@ const makeWsRpcLayer = (...@@ -1259,50 +1253,39 @@ const makeWsRpcLayer = (
1259 Effect.tapError(() => cleanupFailedUploadedAttachments(command, normalizedCommand)),1253 Effect.tapError(() => cleanupFailedUploadedAttachments(command, normalizedCommand)),
1260 );1254 );
1261 yield* recordClientCommandAnalytics(normalizedCommand);1255 yield* recordClientCommandAnalytics(normalizedCommand);
1262 if (parkingCommand) {1256 if (archiveCommand) {
1263 const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle";
1264 if (shouldStopSessionAfterCommand) {1257 if (shouldStopSessionAfterCommand) {
1265 yield* Effect.gen(function* () {1258 yield* Effect.gen(function* () {
1266 const stopCommand = yield* normalizeDispatchCommand({1259 const stopCommand = yield* normalizeDispatchCommand({
1267 type: "thread.session.stop",1260 type: "thread.session.stop",
1268 commandId: CommandId.make(1261 commandId: CommandId.make(
1269 `session-stop-for-${parkingKind}:${parkingCommand.commandId}`,1262 `session-stop-for-archive:${archiveCommand.commandId}`,
1270 ),1263 ),
1271 threadId: parkingCommand.threadId,1264 threadId: archiveCommand.threadId,
1272 createdAt: yield* nowIso,1265 createdAt: yield* nowIso,
1273 // A settled thread can be re-engaged before this stop is
1274 // decided; the decider then drops the stop instead of
1275 // killing the new session. Archive stops stay
1276 // unconditional: turn starts on archived threads are
1277 // rejected, so there is no new session to protect.
1278 ...(parkingKind === "settle" ? { onlyIfSettled: true } : {}),
1279 });1266 });
12801267
1281 yield* dispatchNormalizedCommand(stopCommand);1268 yield* dispatchNormalizedCommand(stopCommand);
1282 }).pipe(1269 }).pipe(
1283 Effect.catchCause((cause) =>1270 Effect.catchCause((cause) =>
1284 Effect.logWarning(`failed to stop provider session during ${parkingKind}`, {1271 Effect.logWarning("failed to stop provider session during archive", {
1285 threadId: parkingCommand.threadId,1272 threadId: archiveCommand.threadId,
1286 cause,1273 cause,
1287 }),1274 }),
1288 ),1275 ),
1289 );1276 );
1290 }1277 }
12911278
1292 // Terminals are user-opened panes, not thread background1279 // Archive removes the thread from view, so its user-opened
1293 // work: archive removes the thread from view so they close1280 // terminal panes close with it.
1294 // with it, but a settled thread stays reachable and may be1281 yield* terminalManager.close({ threadId: archiveCommand.threadId }).pipe(
1295 // un-settled, so its terminals stay up.1282 Effect.catch((error) =>
1296 if (parkingCommand.type === "thread.archive") {1283 Effect.logWarning("failed to close thread terminals after archive", {
1297 yield* terminalManager.close({ threadId: parkingCommand.threadId }).pipe(1284 threadId: archiveCommand.threadId,
1298 Effect.catch((error) =>1285 error: error.message,
1299 Effect.logWarning("failed to close thread terminals after archive", {1286 }),
1300 threadId: parkingCommand.threadId,1287 ),
1301 error: error.message,1288 );
1302 }),
1303 ),
1304 );
1305 }
1306 }1289 }
1307 return result;1290 return result;
1308 }).pipe(1291 }).pipe(
apps/web/src/components/ChatView.tsx+6-55
...@@ -30,12 +30,7 @@ import {...@@ -30,12 +30,7 @@ import {
30} from "@t3tools/client-runtime/connection";30} from "@t3tools/client-runtime/connection";
31import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";31import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
32import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";32import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
33import {33import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
34 changeRequestAutoSettles,
35 effectiveSettled,
36 effectiveSnoozed,
37 threadWokeAt,
38} from "@t3tools/client-runtime/state/thread-settled";
39import {34import {
40 codexFeedbackMessage,35 codexFeedbackMessage,
41 parseCodexFeedbackCommand,36 parseCodexFeedbackCommand,
...@@ -4539,9 +4534,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4539,9 +4534,7 @@ function ChatViewContent(props: ChatViewProps) {
4539 : null,4534 : null,
4540 [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread],4535 [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread],
4541 );4536 );
4542 // Settled state of the open thread, resolved exactly like the sidebar4537 // The server-projected settled state keeps the banner and sidebar in sync.
4543 // partition (same shell, same capability gate, same PR auto-settle input)
4544 // so the banner and the sidebar row never disagree.
4545 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);4538 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
4546 const activeComposerTasksProgress = useMemo(() => {4539 const activeComposerTasksProgress = useMemo(() => {
4547 if (!activeLatestTurn || latestTurnSettled || activePlan?.turnId !== activeLatestTurn.turnId) {4540 if (!activeLatestTurn || latestTurnSettled || activePlan?.turnId !== activeLatestTurn.turnId) {
...@@ -4561,7 +4554,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4561,7 +4554,6 @@ function ChatViewContent(props: ChatViewProps) {
4561 activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId4554 activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId
4562 ? activePlan.steps4555 ? activePlan.steps
4563 : null;4556 : null;
4564
4565 useLayoutEffect(() => {4557 useLayoutEffect(() => {
4566 if (!composerOverlayElement) return;4558 if (!composerOverlayElement) return;
45674559
...@@ -4585,9 +4577,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4585,9 +4577,6 @@ function ChatViewContent(props: ChatViewProps) {
4585 resizeObserver.disconnect();4577 resizeObserver.disconnect();
4586 };4578 };
4587 }, [composerOverlayElement]);4579 }, [composerOverlayElement]);
4588
4589 const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);
4590 const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);
4591 const linkedPullRequestStatus = useLinkedThreadPullRequest(4580 const linkedPullRequestStatus = useLinkedThreadPullRequest(
4592 activeThreadRef?.environmentId ?? null,4581 activeThreadRef?.environmentId ?? null,
4593 linkedThreadPullRequest,4582 linkedThreadPullRequest,
...@@ -4608,18 +4597,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4608,18 +4597,6 @@ function ChatViewContent(props: ChatViewProps) {
4608 }, [activeThreadPr, openThreadPullRequest]);4597 }, [activeThreadPr, openThreadPullRequest]);
4609 const pullRequestSurfaceAvailable =4598 const pullRequestSurfaceAvailable =
4610 supportsPullRequests && activeThreadPr !== null && threadRepository !== null;4599 supportsPullRequests && activeThreadPr !== null && threadRepository !== null;
4611 // Primitive slice of the displayed PR for the settle-rule memos below:
4612 // resolveDisplayedThreadPr returns a fresh object every render, so memoize
4613 // on the fields the rules read instead of the object identity.
4614 const activeThreadPrState = activeThreadPr?.state ?? null;
4615 const activeThreadPrUpdatedAt = activeThreadPr?.updatedAt ?? null;
4616 const activeThreadChangeRequest = useMemo(
4617 () =>
4618 activeThreadPrState === null
4619 ? null
4620 : { state: activeThreadPrState, updatedAt: activeThreadPrUpdatedAt },
4621 [activeThreadPrState, activeThreadPrUpdatedAt],
4622 );
4623 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;4600 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
4624 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;4601 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
4625 const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true;4602 const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true;
...@@ -4650,21 +4627,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4650,21 +4627,13 @@ function ChatViewContent(props: ChatViewProps) {
4650 if (activeThreadRef === null || activeThreadWokeAt === null) return;4627 if (activeThreadRef === null || activeThreadWokeAt === null) return;
4651 markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);4628 markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
4652 }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);4629 }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
4653 // Mirror of the sidebar's Woke pill for the open thread. It uses the same4630 // Mirror of the sidebar's Woke pill for the open thread.
4654 // visit comparison and change request settle rule.
4655 const activeThreadLastVisitedAt = useUiStateStore((store) =>4631 const activeThreadLastVisitedAt = useUiStateStore((store) =>
4656 activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey],4632 activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey],
4657 );4633 );
4658 const activeThreadWokeVisible = useMemo(() => {4634 const activeThreadWokeVisible = useMemo(() => {
4659 if (activeThreadWokeAt === null) return false;4635 if (activeThreadWokeAt === null) return false;
4660 if (4636 if (activeThreadShell?.settledOverride === "settled") return false;
4661 changeRequestAutoSettles(activeThreadChangeRequest, {
4662 autoSettleOnMerge,
4663 thread: activeThreadShell,
4664 })
4665 ) {
4666 return false;
4667 }
4668 const wokeAtMs = Date.parse(activeThreadWokeAt);4637 const wokeAtMs = Date.parse(activeThreadWokeAt);
4669 if (Number.isNaN(wokeAtMs)) return false;4638 if (Number.isNaN(wokeAtMs)) return false;
4670 // Having the thread open counts as a visit at completedAt (the effect4639 // Having the thread open counts as a visit at completedAt (the effect
...@@ -4684,28 +4653,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4684,28 +4653,11 @@ function ChatViewContent(props: ChatViewProps) {
4684 }, [4653 }, [
4685 activeLatestTurn?.completedAt,4654 activeLatestTurn?.completedAt,
4686 activeThreadLastVisitedAt,4655 activeThreadLastVisitedAt,
4687 activeThreadChangeRequest,
4688 activeThreadShell,4656 activeThreadShell,
4689 activeThreadWokeAt,4657 activeThreadWokeAt,
4690 autoSettleOnMerge,
4691 ]);
4692 const activeThreadSettled = useMemo(() => {
4693 if (activeThreadShell === null || !supportsSettlement) return false;
4694 return effectiveSettled(activeThreadShell, {
4695 now: `${nowMinute}:00.000Z`,
4696 autoSettleAfterDays,
4697 autoSettleOnMerge,
4698 changeRequest: activeThreadChangeRequest,
4699 });
4700 }, [
4701 activeThreadChangeRequest,
4702 activeThreadShell,
4703 autoSettleAfterDays,
4704 autoSettleOnMerge,
4705 changeRequestSnapshotByKey,
4706 nowMinute,
4707 supportsSettlement,
4708 ]);4658 ]);
4659 const activeThreadSettled =
4660 supportsSettlement && activeThreadShell?.settledOverride === "settled";
4709 const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, {4661 const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, {
4710 reportFailure: false,4662 reportFailure: false,
4711 });4663 });
...@@ -7158,7 +7110,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7158,7 +7110,6 @@ function ChatViewContent(props: ChatViewProps) {
7158 {...(routeKind === "draft" && draftId ? { draftId } : {})}7110 {...(routeKind === "draft" && draftId ? { draftId } : {})}
7159 activeThreadTitle={activeThread.title}7111 activeThreadTitle={activeThread.title}
7160 isServerThread={isServerThread}7112 isServerThread={isServerThread}
7161 changeRequest={activeThreadChangeRequest}
7162 activeProjectName={activeProject?.title}7113 activeProjectName={activeProject?.title}
7163 activeProjectCwd={activeProject?.workspaceRoot ?? null}7114 activeProjectCwd={activeProject?.workspaceRoot ?? null}
7164 activeProjectFaviconPath={activeProject?.faviconPath ?? null}7115 activeProjectFaviconPath={activeProject?.faviconPath ?? null}
apps/web/src/components/Sidebar.logic.ts+2-5
...@@ -618,11 +618,8 @@ type SettledTimestampInput = Pick<...@@ -618,11 +618,8 @@ type SettledTimestampInput = Pick<
618 "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt"618 "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt"
619>;619>;
620620
621/** The timestamp a settled row sorts and labels by: settledAt when stamped621/** The timestamp a settled row sorts and labels by: settledAt when stamped,
622 (explicit settles), otherwise last activity — the same candidates622 otherwise the latest message or turn stamp. updatedAt is the final net. */
623 threadLastActivityAt feeds the auto-settle window (user message plus all
624 latestTurn stamps), so a thread whose last activity was a turn completion
625 doesn't sort by an older message time. updatedAt is the final net. */
626export function resolveSettledTimestamp(thread: SettledTimestampInput): string | null {623export function resolveSettledTimestamp(thread: SettledTimestampInput): string | null {
627 const settledAt = firstValidTimestamp(thread.settledAt);624 const settledAt = firstValidTimestamp(thread.settledAt);
628 if (settledAt !== null) return settledAt;625 if (settledAt !== null) return settledAt;
apps/web/src/components/Sidebar.tsx+6-49
...@@ -19,8 +19,6 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-...@@ -19,8 +19,6 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-
19import { CSS } from "@dnd-kit/utilities";19import { CSS } from "@dnd-kit/utilities";
20import {20import {
21 canSnooze,21 canSnooze,
22 changeRequestAutoSettles,
23 effectiveSettled,
24 effectiveSnoozed,22 effectiveSnoozed,
25 threadWokeAt,23 threadWokeAt,
26} from "@t3tools/client-runtime/state/thread-settled";24} from "@t3tools/client-runtime/state/thread-settled";
...@@ -715,7 +713,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -715,7 +713,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
715 // False on environments whose server predates thread.settle/unsettle:713 // False on environments whose server predates thread.settle/unsettle:
716 // the lifecycle affordances hide entirely rather than fail on click.714 // the lifecycle affordances hide entirely rather than fail on click.
717 settlementSupported: boolean;715 settlementSupported: boolean;
718 autoSettleOnMerge: boolean;
719 // Same contract for thread.snooze/unsnooze.716 // Same contract for thread.snooze/unsnooze.
720 snoozeSupported: boolean;717 snoozeSupported: boolean;
721 // Pinned threads show the same pin marker in active, settled, and snoozed718 // Pinned threads show the same pin marker in active, settled, and snoozed
...@@ -840,10 +837,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -840,10 +837,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
840 const isWoke =837 const isWoke =
841 wokeAtDate !== null &&838 wokeAtDate !== null &&
842 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&839 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
843 !changeRequestAutoSettles(pr, {840 thread.settledOverride !== "settled";
844 autoSettleOnMerge: props.autoSettleOnMerge,
845 thread,
846 });
847 // In-flight rows (working, or waiting on approval/input) fade as a whole:841 // In-flight rows (working, or waiting on approval/input) fade as a whole:
848 // there is nothing for the user to do yet, so prominence is reserved for842 // there is nothing for the user to do yet, so prominence is reserved for
849 // rows that need a human — done (unread), read-but-unsettled, failed, and843 // rows that need a human — done (unread), read-but-unsettled, failed, and
...@@ -1740,8 +1734,6 @@ export default function Sidebar() {...@@ -1740,8 +1734,6 @@ export default function Sidebar() {
1740 const router = useRouter();1734 const router = useRouter();
1741 const { isMobile, setOpenMobile } = useSidebar();1735 const { isMobile, setOpenMobile } = useSidebar();
1742 const keybindings = useAtomValue(primaryServerKeybindingsAtom);1736 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
1743 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
1744 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);
1745 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);1737 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
1746 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);1738 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
1747 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);1739 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
...@@ -1938,8 +1930,6 @@ export default function Sidebar() {...@@ -1938,8 +1930,6 @@ export default function Sidebar() {
1938 [projectGroups],1930 [projectGroups],
1939 );1931 );
19401932
1941 // now is quantized to the minute so effectiveSettled memoization doesn't
1942 // churn on every render; auto-settle thresholds are day-granular anyway.
1943 const nowMinute = useNowMinute();1933 const nowMinute = useNowMinute();
1944 // Snooze wake times are second-precise, so classifying with the quantized1934 // Snooze wake times are second-precise, so classifying with the quantized
1945 // minute would hold a woken thread on the shelf for up to a minute. The1935 // minute would hold a woken thread on the shelf for up to a minute. The
...@@ -2081,7 +2071,6 @@ export default function Sidebar() {...@@ -2081,7 +2071,6 @@ export default function Sidebar() {
2081 settledThreads,2071 settledThreads,
2082 snoozeNow,2072 snoozeNow,
2083 } = useMemo(() => {2073 } = useMemo(() => {
2084 const now = `${nowMinute}:00.000Z`;
2085 // Snooze classification uses a REAL clock, not the quantized minute:2074 // Snooze classification uses a REAL clock, not the quantized minute:
2086 // wake times are second-precise and a woken thread must not linger on2075 // wake times are second-precise and a woken thread must not linger on
2087 // the shelf for the rest of the minute. snoozeWakeTick re-runs this2076 // the shelf for the rest of the minute. snoozeWakeTick re-runs this
...@@ -2107,29 +2096,10 @@ export default function Sidebar() {...@@ -2107,29 +2096,10 @@ export default function Sidebar() {
2107 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true;2096 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true;
2108 const supportsSnooze =2097 const supportsSnooze =
2109 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;2098 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
2110 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
2111 const snapshot = changeRequestSnapshotByKey.get(threadKey);
2112 const changeRequest =
2113 snapshot != null &&
2114 (thread.linkedPullRequest == null
2115 ? thread.worktreePath === null || snapshot.branch === thread.branch
2116 : snapshot.linkedPullRequest?.projectId === thread.linkedPullRequest.projectId &&
2117 snapshot.linkedPullRequest.repository === thread.linkedPullRequest.repository &&
2118 snapshot.linkedPullRequest.number === thread.linkedPullRequest.number)
2119 ? snapshot.pr
2120 : null;
2121 // Snooze outranks settlement and pinning until the thread wakes.2099 // Snooze outranks settlement and pinning until the thread wakes.
2122 if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {2100 if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
2123 snoozed.push(thread);2101 snoozed.push(thread);
2124 } else if (2102 } else if (supportsSettlement && thread.settledOverride === "settled") {
2125 supportsSettlement &&
2126 effectiveSettled(thread, {
2127 now,
2128 autoSettleAfterDays,
2129 autoSettleOnMerge,
2130 changeRequest,
2131 })
2132 ) {
2133 settled.push(thread);2103 settled.push(thread);
2134 } else if (thread.pinnedAt != null) {2104 } else if (thread.pinnedAt != null) {
2135 pinned.push(thread);2105 pinned.push(thread);
...@@ -2163,16 +2133,7 @@ export default function Sidebar() {...@@ -2163,16 +2133,7 @@ export default function Sidebar() {
2163 settledThreads: sortSettledThreadsForSidebar(settled),2133 settledThreads: sortSettledThreadsForSidebar(settled),
2164 snoozeNow: preciseNow,2134 snoozeNow: preciseNow,
2165 };2135 };
2166 }, [2136 }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]);
2167 autoSettleAfterDays,
2168 autoSettleOnMerge,
2169 changeRequestSnapshotByKey,
2170 nowMinute,
2171 scopedProjectKeys,
2172 serverConfigs,
2173 snoozeWakeTick,
2174 threads,
2175 ]);
21762137
2177 const threadSearchInputRef = useRef<HTMLInputElement>(null);2138 const threadSearchInputRef = useRef<HTMLInputElement>(null);
2178 const [threadSearchQuery, setThreadSearchQuery] = useState("");2139 const [threadSearchQuery, setThreadSearchQuery] = useState("");
...@@ -3111,9 +3072,8 @@ export default function Sidebar() {...@@ -3111,9 +3072,8 @@ export default function Sidebar() {
3111 thread.worktreePath ??3072 thread.worktreePath ??
3112 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ??3073 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
3113 null;3074 null;
3114 // Un-settle works on every settled row: for explicit settles it3075 // Un-settle pins the thread active until real activity clears the pin.
3115 // clears the override, for auto-settled rows it pins the thread3076 // Environments without
3116 // active until real activity clears the pin. Environments without
3117 // the settlement capability get no lifecycle items at all.3077 // the settlement capability get no lifecycle items at all.
3118 const supportsSettlement =3078 const supportsSettlement =
3119 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement ===3079 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement ===
...@@ -3777,9 +3737,7 @@ export default function Sidebar() {...@@ -3777,9 +3737,7 @@ export default function Sidebar() {
3777 key={`${threadKey}:${rowVariant}`}3737 key={`${threadKey}:${rowVariant}`}
3778 thread={thread}3738 thread={thread}
3779 variant={rowVariant}3739 variant={rowVariant}
3780 // Snoozed rows wake; settled rows un-settle (explicit3740 // Snoozed rows wake, settled rows un-settle, and cards settle.
3781 // settles clear the override, auto-settled rows get
3782 // pinned active); cards settle.
3783 variantAction={3741 variantAction={
3784 section === "snoozed"3742 section === "snoozed"
3785 ? "unsnooze"3743 ? "unsnooze"
...@@ -3791,7 +3749,6 @@ export default function Sidebar() {...@@ -3791,7 +3749,6 @@ export default function Sidebar() {
3791 serverConfigs.get(thread.environmentId)?.environment.capabilities3749 serverConfigs.get(thread.environmentId)?.environment.capabilities
3792 .threadSettlement === true3750 .threadSettlement === true
3793 }3751 }
3794 autoSettleOnMerge={autoSettleOnMerge}
3795 snoozeSupported={3752 snoozeSupported={
3796 serverConfigs.get(thread.environmentId)?.environment.capabilities3753 serverConfigs.get(thread.environmentId)?.environment.capabilities
3797 .threadSnooze === true3754 .threadSnooze === true
apps/web/src/components/ThreadStatusIndicators.test.ts+2-34
...@@ -1,6 +1,4 @@...@@ -1,6 +1,4 @@
1import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";1import { ProjectId, type VcsStatusResult } from "@t3tools/contracts";
2import type { OrchestrationThreadShell } from "@t3tools/contracts";
3import { ProjectId, ProviderInstanceId, ThreadId, type VcsStatusResult } from "@t3tools/contracts";
4import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
6import { AtomRegistry } from "effect/unstable/reactivity";4import { AtomRegistry } from "effect/unstable/reactivity";
...@@ -495,7 +493,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {...@@ -495,7 +493,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
495 ).toEqual(mergedPr);493 ).toEqual(mergedPr);
496 });494 });
497495
498 it("keeps effectiveSettled true for a retained merged PR after a main checkout", () => {496 it("retains a merged PR after a main checkout", () => {
499 const matchingStatus = status({497 const matchingStatus = status({
500 refName: featureBranch,498 refName: featureBranch,
501 pr: mergedPr,499 pr: mergedPr,
...@@ -518,36 +516,6 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {...@@ -518,36 +516,6 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
518 retainTerminalOnBranchMismatch: true,516 retainTerminalOnBranchMismatch: true,
519 });517 });
520 expect(displayed?.state).toBe("merged");518 expect(displayed?.state).toBe("merged");
521
522 const shell = {
523 id: ThreadId.make("thread-1"),
524 projectId: ProjectId.make("project-1"),
525 title: "Feature thread",
526 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
527 runtimeMode: "full-access",
528 interactionMode: "default",
529 branch: "main",
530 worktreePath: null,
531 latestTurn: null,
532 session: null,
533 createdAt: "2026-04-09T00:00:00.000Z",
534 updatedAt: "2026-04-09T00:00:00.000Z",
535 archivedAt: null,
536 settledAt: null,
537 settledOverride: null,
538 latestUserMessageAt: "2026-04-09T00:00:00.000Z",
539 hasPendingApprovals: false,
540 hasPendingUserInput: false,
541 hasActionableProposedPlan: false,
542 } as OrchestrationThreadShell;
543
544 expect(
545 effectiveSettled(shell, {
546 now: "2026-04-10T00:00:00.000Z",
547 autoSettleAfterDays: null,
548 changeRequest: displayed,
549 }),
550 ).toBe(true);
551 });519 });
552});520});
553521
apps/web/src/components/chat/ChatHeader.tsx-5
...@@ -10,7 +10,6 @@ import {...@@ -10,7 +10,6 @@ import {
10 isAtomCommandInterrupted,10 isAtomCommandInterrupted,
11 squashAtomCommandFailure,11 squashAtomCommandFailure,
12} from "@t3tools/client-runtime/state/runtime";12} from "@t3tools/client-runtime/state/runtime";
13import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
14import { ChevronDownIcon } from "lucide-react";13import { ChevronDownIcon } from "lucide-react";
15import {14import {
16 memo,15 memo,
...@@ -53,8 +52,6 @@ interface ChatHeaderProps {...@@ -53,8 +52,6 @@ interface ChatHeaderProps {
53 activeThreadTitle: string;52 activeThreadTitle: string;
54 /** Drafts have no server thread yet, so the title carries no action menu. */53 /** Drafts have no server thread yet, so the title carries no action menu. */
55 isServerThread: boolean;54 isServerThread: boolean;
56 /** PR feeding the settled classification, resolved by ChatView. */
57 changeRequest: ChangeRequestSettleSource | null;
58 activeProjectName: string | undefined;55 activeProjectName: string | undefined;
59 activeProjectCwd: string | null;56 activeProjectCwd: string | null;
60 activeProjectFaviconPath: string | null;57 activeProjectFaviconPath: string | null;
...@@ -123,7 +120,6 @@ export const ChatHeader = memo(function ChatHeader({...@@ -123,7 +120,6 @@ export const ChatHeader = memo(function ChatHeader({
123 draftId,120 draftId,
124 activeThreadTitle,121 activeThreadTitle,
125 isServerThread,122 isServerThread,
126 changeRequest,
127 activeProjectName,123 activeProjectName,
128 activeProjectCwd,124 activeProjectCwd,
129 activeProjectFaviconPath,125 activeProjectFaviconPath,
...@@ -201,7 +197,6 @@ export const ChatHeader = memo(function ChatHeader({...@@ -201,7 +197,6 @@ export const ChatHeader = memo(function ChatHeader({
201 const { openMenu, closeMenu } = useThreadActionMenu({197 const { openMenu, closeMenu } = useThreadActionMenu({
202 threadRef: isServerThread ? activeThreadRef : null,198 threadRef: isServerThread ? activeThreadRef : null,
203 projectCwd: activeProjectCwd,199 projectCwd: activeProjectCwd,
204 changeRequest,
205 onStartRename: startRename,200 onStartRename: startRename,
206 });201 });
207 const titleButtonRef = useRef<HTMLButtonElement | null>(null);202 const titleButtonRef = useRef<HTMLButtonElement | null>(null);
apps/web/src/components/settings/SettingsPanels.tsx+72-61
...@@ -79,7 +79,11 @@ import {...@@ -79,7 +79,11 @@ import {
79} from "../../providerInstances";79} from "../../providerInstances";
80import { ensureLocalApi, readLocalApi } from "../../localApi";80import { ensureLocalApi, readLocalApi } from "../../localApi";
81import { isMacPlatform } from "../../lib/utils";81import { isMacPlatform } from "../../lib/utils";
82import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server";82import {
83 primaryServerConfigAtom,
84 primaryServerObservabilityAtom,
85 primaryServerProvidersAtom,
86} from "../../state/server";
83import { useProjects } from "../../state/entities";87import { useProjects } from "../../state/entities";
84import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";88import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
85import { formatRelativeTimeLabel } from "../../timestampFormat";89import { formatRelativeTimeLabel } from "../../timestampFormat";
...@@ -1871,6 +1875,8 @@ export function GeneralSettingsPanel() {...@@ -1871,6 +1875,8 @@ export function GeneralSettingsPanel() {
1871 );1875 );
1872 const observability = useAtomValue(primaryServerObservabilityAtom);1876 const observability = useAtomValue(primaryServerObservabilityAtom);
1873 const serverProviders = useAtomValue(primaryServerProvidersAtom);1877 const serverProviders = useAtomValue(primaryServerProvidersAtom);
1878 const supportsAutoSettlement =
1879 useAtomValue(primaryServerConfigAtom)?.environment.capabilities.threadAutoSettlement === true;
1874 const diagnosticsDescription = formatDiagnosticsDescription({1880 const diagnosticsDescription = formatDiagnosticsDescription({
1875 localTracingEnabled: observability?.localTracingEnabled ?? false,1881 localTracingEnabled: observability?.localTracingEnabled ?? false,
1876 otlpTracesEnabled: observability?.otlpTracesEnabled ?? false,1882 otlpTracesEnabled: observability?.otlpTracesEnabled ?? false,
...@@ -1954,72 +1960,77 @@ export function GeneralSettingsPanel() {...@@ -1954,72 +1960,77 @@ export function GeneralSettingsPanel() {
1954 }1960 }
1955 />1961 />
19561962
1957 <SettingsRow1963 {supportsAutoSettlement ? (
1958 {...searchableSetting("auto-settle-merged-threads")}1964 <>
1959 description="Settle a thread when its pull request merges. Closed pull requests still settle automatically."1965 <SettingsRow
1960 resetAction={1966 {...searchableSetting("auto-settle-merged-threads")}
1961 settings.sidebarAutoSettleOnMerge !==1967 description="Settle a thread when its pull request merges. Closed pull requests still settle automatically."
1962 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge ? (1968 resetAction={
1963 <SettingResetButton1969 settings.sidebarAutoSettleOnMerge !==
1964 label="auto-settle on merge"1970 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge ? (
1965 onClick={() =>1971 <SettingResetButton
1966 updateSettings({1972 label="auto-settle on merge"
1967 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,1973 onClick={() =>
1968 })1974 updateSettings({
1969 }1975 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
1970 />1976 })
1971 ) : null1977 }
1972 }1978 />
1973 control={1979 ) : null
1974 <Switch1980 }
1975 checked={settings.sidebarAutoSettleOnMerge}1981 control={
1976 onCheckedChange={(checked) =>1982 <Switch
1977 updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) })1983 checked={settings.sidebarAutoSettleOnMerge}
1984 onCheckedChange={(checked) =>
1985 updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) })
1986 }
1987 aria-label="Auto-settle merged threads"
1988 />
1978 }1989 }
1979 aria-label="Auto-settle merged threads"
1980 />1990 />
1981 }
1982 />
19831991
1984 <SettingsRow1992 <SettingsRow
1985 {...searchableSetting("auto-settle-inactive-threads")}1993 {...searchableSetting("auto-settle-inactive-threads")}
1986 description="Sidebar threads with no activity for this long settle automatically."1994 description="Sidebar threads with no activity for this long settle automatically."
1987 resetAction={1995 resetAction={
1988 settings.sidebarAutoSettleAfterDays !==1996 settings.sidebarAutoSettleAfterDays !==
1989 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? (1997 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? (
1990 <SettingResetButton1998 <SettingResetButton
1991 label="auto-settle"1999 label="auto-settle"
1992 onClick={() =>2000 onClick={() =>
1993 updateSettings({2001 updateSettings({
1994 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,2002 sidebarAutoSettleAfterDays:
1995 })2003 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
1996 }2004 })
1997 />2005 }
1998 ) : null2006 />
1999 }2007 ) : null
2000 control={2008 }
2001 <Switch2009 control={
2002 checked={settings.sidebarAutoSettleAfterDays !== null}2010 <Switch
2003 onCheckedChange={(checked) =>2011 checked={settings.sidebarAutoSettleAfterDays !== null}
2004 updateSettings({2012 onCheckedChange={(checked) =>
2005 sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null,2013 updateSettings({
2006 })2014 sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null,
2015 })
2016 }
2017 aria-label="Auto-settle inactive threads"
2018 />
2007 }2019 }
2008 aria-label="Auto-settle inactive threads"
2009 />2020 />
2010 }2021 {settings.sidebarAutoSettleAfterDays !== null ? (
2011 />2022 <SettingsRow
2012 {settings.sidebarAutoSettleAfterDays !== null ? (2023 title={searchableSetting("days-before-auto-settle").title}
2013 <SettingsRow2024 description="Any new activity un-settles a thread automatically."
2014 title={searchableSetting("days-before-auto-settle").title}2025 control={
2015 description="Any new activity un-settles a thread automatically."2026 <AutoSettleDaysInput
2016 control={2027 value={settings.sidebarAutoSettleAfterDays}
2017 <AutoSettleDaysInput2028 onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}
2018 value={settings.sidebarAutoSettleAfterDays}2029 />
2019 onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}2030 }
2020 />2031 />
2021 }2032 ) : null}
2022 />2033 </>
2023 ) : null}2034 ) : null}
20242035
2025 <SettingsRow2036 <SettingsRow
apps/web/src/components/settings/settingsSearch.test.ts+22-1
...@@ -104,7 +104,7 @@ describe("searchSettings", () => {...@@ -104,7 +104,7 @@ describe("searchSettings", () => {
104104
105 it("hides desktop-only settings from browser search", () => {105 it("hides desktop-only settings from browser search", () => {
106 expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true);106 expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true);
107 expect(searchSettings("quit confirmation")).toEqual([]);107 expect(searchSettings("hold to quit")).toEqual([]);
108 expect(searchSettings("wsl")).toEqual([]);108 expect(searchSettings("wsl")).toEqual([]);
109 });109 });
110110
...@@ -134,6 +134,7 @@ describe("searchSettings", () => {...@@ -134,6 +134,7 @@ describe("searchSettings", () => {
134 hasProviderSettingsEnvironment: false,134 hasProviderSettingsEnvironment: false,
135 canManageLocalBackend: false,135 canManageLocalBackend: false,
136 isWslSettingsRowVisible: false,136 isWslSettingsRowVisible: false,
137 hasThreadAutoSettlement: false,
137 });138 });
138139
139 const gatedIds = new Set<string>([140 const gatedIds = new Set<string>([
...@@ -147,10 +148,30 @@ describe("searchSettings", () => {...@@ -147,10 +148,30 @@ describe("searchSettings", () => {
147 "t3-connect",148 "t3-connect",
148 "tailscale-https",149 "tailscale-https",
149 "wsl-backend",150 "wsl-backend",
151 "auto-settle-inactive-threads",
152 "auto-settle-merged-threads",
153 "days-before-auto-settle",
150 ]);154 ]);
151 expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]);155 expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]);
152 });156 });
153157
158 it("shows automatic settlement settings when the server supports them", () => {
159 const available = filterAvailableSettingsSearchItems({
160 hasCloudPublicConfig: false,
161 hasPrimaryEnvironment: false,
162 hasProviderSettingsEnvironment: false,
163 canManageLocalBackend: false,
164 isWslSettingsRowVisible: false,
165 hasThreadAutoSettlement: true,
166 });
167
168 expect(searchSettings("auto-settle", available).map((item) => item.id)).toEqual([
169 "auto-settle-inactive-threads",
170 "auto-settle-merged-threads",
171 "days-before-auto-settle",
172 ]);
173 });
174
154 it("keeps catalog result ids unique", () => {175 it("keeps catalog result ids unique", () => {
155 const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);176 const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);
156 expect(new Set(ids).size).toBe(ids.length);177 expect(new Set(ids).size).toBe(ids.length);
apps/web/src/components/settings/settingsSearch.ts+7-1
...@@ -30,6 +30,7 @@ export interface SettingsSearchItem {...@@ -30,6 +30,7 @@ export interface SettingsSearchItem {
30 readonly providerSettingsOnly?: boolean;30 readonly providerSettingsOnly?: boolean;
31 readonly localBackendManagementOnly?: boolean;31 readonly localBackendManagementOnly?: boolean;
32 readonly wslAvailableOnly?: boolean;32 readonly wslAvailableOnly?: boolean;
33 readonly requiresThreadAutoSettlement?: boolean;
33}34}
3435
35export interface SettingsSearchAvailability {36export interface SettingsSearchAvailability {
...@@ -38,6 +39,7 @@ export interface SettingsSearchAvailability {...@@ -38,6 +39,7 @@ export interface SettingsSearchAvailability {
38 readonly hasProviderSettingsEnvironment: boolean;39 readonly hasProviderSettingsEnvironment: boolean;
39 readonly canManageLocalBackend: boolean;40 readonly canManageLocalBackend: boolean;
40 readonly isWslSettingsRowVisible: boolean;41 readonly isWslSettingsRowVisible: boolean;
42 readonly hasThreadAutoSettlement: boolean;
41}43}
4244
43/**45/**
...@@ -148,12 +150,14 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -148,12 +150,14 @@ export const SETTINGS_SEARCH_ITEMS = [
148 title: "Auto-settle inactive threads",150 title: "Auto-settle inactive threads",
149 to: "/settings/general",151 to: "/settings/general",
150 searchTerms: ["sidebar inactivity days no activity automatically"],152 searchTerms: ["sidebar inactivity days no activity automatically"],
153 requiresThreadAutoSettlement: true,
151 },154 },
152 {155 {
153 id: "auto-settle-merged-threads",156 id: "auto-settle-merged-threads",
154 title: "Auto-settle merged threads",157 title: "Auto-settle merged threads",
155 to: "/settings/general",158 to: "/settings/general",
156 searchTerms: ["pull request merge closed automatically sidebar"],159 searchTerms: ["pull request merge closed automatically sidebar"],
160 requiresThreadAutoSettlement: true,
157 },161 },
158 {162 {
159 id: "days-before-auto-settle",163 id: "days-before-auto-settle",
...@@ -161,6 +165,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -161,6 +165,7 @@ export const SETTINGS_SEARCH_ITEMS = [
161 to: "/settings/general",165 to: "/settings/general",
162 targetId: "auto-settle-inactive-threads",166 targetId: "auto-settle-inactive-threads",
163 searchTerms: ["thread timeout activity sidebar"],167 searchTerms: ["thread timeout activity sidebar"],
168 requiresThreadAutoSettlement: true,
164 },169 },
165 {170 {
166 id: "time-format",171 id: "time-format",
...@@ -461,7 +466,8 @@ export function filterAvailableSettingsSearchItems(...@@ -461,7 +466,8 @@ export function filterAvailableSettingsSearchItems(
461 (!item.primaryOnly || availability.hasPrimaryEnvironment) &&466 (!item.primaryOnly || availability.hasPrimaryEnvironment) &&
462 (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) &&467 (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) &&
463 (!item.localBackendManagementOnly || availability.canManageLocalBackend) &&468 (!item.localBackendManagementOnly || availability.canManageLocalBackend) &&
464 (!item.wslAvailableOnly || availability.isWslSettingsRowVisible),469 (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) &&
470 (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement),
465 );471 );
466}472}
467473
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+13-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { useMemo } from "react";1import { useMemo } from "react";
2import { useAtomValue } from "@effect/atom-react";
2import { AuthAccessWriteScope } from "@t3tools/contracts";3import { AuthAccessWriteScope } from "@t3tools/contracts";
34
4import { hasCloudPublicConfig } from "~/cloud/publicConfig";5import { hasCloudPublicConfig } from "~/cloud/publicConfig";
...@@ -7,6 +8,7 @@ import { desktopWslStateAtom } from "~/state/desktopWslState";...@@ -7,6 +8,7 @@ import { desktopWslStateAtom } from "~/state/desktopWslState";
7import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";8import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
8import { useEnvironmentQuery } from "~/state/query";9import { useEnvironmentQuery } from "~/state/query";
9import { usePrimarySessionState } from "~/environments/primary";10import { usePrimarySessionState } from "~/environments/primary";
11import { primaryServerConfigAtom } from "~/state/server";
10import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";12import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
11import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";13import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";
12import { filterAvailableSettingsSearchItems } from "./settingsSearch";14import { filterAvailableSettingsSearchItems } from "./settingsSearch";
...@@ -15,6 +17,7 @@ export function useAvailableSettingsSearchItems() {...@@ -15,6 +17,7 @@ export function useAvailableSettingsSearchItems() {
15 const primaryEnvironmentId = usePrimaryEnvironmentId();17 const primaryEnvironmentId = usePrimaryEnvironmentId();
16 const { environments } = useEnvironments();18 const { environments } = useEnvironments();
17 const primarySessionState = usePrimarySessionState();19 const primarySessionState = usePrimarySessionState();
20 const primaryServerConfig = useAtomValue(primaryServerConfigAtom);
18 const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null);21 const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null);
19 const canManageLocalBackend =22 const canManageLocalBackend =
20 isElectron ||23 isElectron ||
...@@ -38,7 +41,16 @@ export function useAvailableSettingsSearchItems() {...@@ -38,7 +41,16 @@ export function useAvailableSettingsSearchItems() {
38 state: desktopWsl.data,41 state: desktopWsl.data,
39 error: desktopWsl.error,42 error: desktopWsl.error,
40 }),43 }),
44 hasThreadAutoSettlement:
45 primaryServerConfig?.environment.capabilities.threadAutoSettlement === true,
41 }),46 }),
42 [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments, primaryEnvironmentId],47 [
48 canManageLocalBackend,
49 desktopWsl.data,
50 desktopWsl.error,
51 environments,
52 primaryEnvironmentId,
53 primaryServerConfig,
54 ],
43 );55 );
44}56}
apps/web/src/hooks/useNowMinute.ts+2-5
...@@ -1,10 +1,7 @@...@@ -1,10 +1,7 @@
1import { useSyncExternalStore } from "react";1import { useSyncExternalStore } from "react";
22
3/** Minute-quantized clock ("YYYY-MM-DDTHH:MM") for settled-state resolution.3/** Minute-quantized UI clock ("YYYY-MM-DDTHH:MM"). One module-level timer
4 One module-level timer feeds every consumer through useSyncExternalStore,4 feeds every consumer through useSyncExternalStore. */
5 so all surfaces resolving effectiveSettled against it (sidebar partition,
6 composer banner) share a single value by construction and tick on UTC
7 minute boundaries together. */
85
9function currentMinute(): string {6function currentMinute(): string {
10 return new Date().toISOString().slice(0, 16);7 return new Date().toISOString().slice(0, 16);
apps/web/src/hooks/useSettings.test.ts+18
...@@ -76,4 +76,22 @@ describe("mergeEnvironmentSettings", () => {...@@ -76,4 +76,22 @@ describe("mergeEnvironmentSettings", () => {
76 expect(settings.providerInstances).toBe(serverSettings.providerInstances);76 expect(settings.providerInstances).toBe(serverSettings.providerInstances);
77 expect(settings.favorites).toBe(clientSettings.favorites);77 expect(settings.favorites).toBe(clientSettings.favorites);
78 });78 });
79
80 it("keeps server settlement settings when legacy client data contains retired keys", () => {
81 const serverSettings = {
82 ...DEFAULT_SERVER_SETTINGS,
83 sidebarAutoSettleAfterDays: 14,
84 sidebarAutoSettleOnMerge: false,
85 };
86 const legacyClientSettings = {
87 ...DEFAULT_CLIENT_SETTINGS,
88 sidebarAutoSettleAfterDays: 1,
89 sidebarAutoSettleOnMerge: true,
90 };
91
92 const settings = mergeEnvironmentSettings(serverSettings, legacyClientSettings);
93
94 expect(settings.sidebarAutoSettleAfterDays).toBe(14);
95 expect(settings.sidebarAutoSettleOnMerge).toBe(false);
96 });
79});97});
apps/web/src/hooks/useSettings.ts+3-1
...@@ -216,7 +216,9 @@ export function mergeEnvironmentSettings(...@@ -216,7 +216,9 @@ export function mergeEnvironmentSettings(
216 serverSettings: ServerSettings,216 serverSettings: ServerSettings,
217 clientSettings: ClientSettings,217 clientSettings: ClientSettings,
218): UnifiedSettings {218): UnifiedSettings {
219 return { ...serverSettings, ...clientSettings };219 // Decode drops retired client keys, but older untyped persistence adapters
220 // can still return them. Server-owned values must always win.
221 return { ...clientSettings, ...serverSettings };
220}222}
221223
222function useMergedSettings<T>(224function useMergedSettings<T>(
apps/web/src/hooks/useThreadActionMenu.ts+3-25
...@@ -5,12 +5,7 @@ import {...@@ -5,12 +5,7 @@ import {
5 settlePromise,5 settlePromise,
6 squashAtomCommandFailure,6 squashAtomCommandFailure,
7} from "@t3tools/client-runtime/state/runtime";7} from "@t3tools/client-runtime/state/runtime";
8import {8import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
9 canSnooze,
10 effectiveSettled,
11 effectiveSnoozed,
12 type ChangeRequestSettleSource,
13} from "@t3tools/client-runtime/state/thread-settled";
14import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";9import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";
15import { useCallback } from "react";10import { useCallback } from "react";
1611
...@@ -60,11 +55,9 @@ export function useThreadActionMenu(input: {...@@ -60,11 +55,9 @@ export function useThreadActionMenu(input: {
60 readonly threadRef: ScopedThreadRef | null;55 readonly threadRef: ScopedThreadRef | null;
61 /** Fallback for "Copy path" when the thread has no worktree. */56 /** Fallback for "Copy path" when the thread has no worktree. */
62 readonly projectCwd: string | null;57 readonly projectCwd: string | null;
63 /** PR feeding auto-settle classification, as resolved by the caller. */
64 readonly changeRequest: ChangeRequestSettleSource | null;
65 readonly onStartRename: () => void;58 readonly onStartRename: () => void;
66}) {59}) {
67 const { threadRef, projectCwd, changeRequest, onStartRename } = input;60 const { threadRef, projectCwd, onStartRename } = input;
68 const {61 const {
69 settleThread,62 settleThread,
70 unsettleThread,63 unsettleThread,
...@@ -80,8 +73,6 @@ export function useThreadActionMenu(input: {...@@ -80,8 +73,6 @@ export function useThreadActionMenu(input: {
80 });73 });
81 const handleNewThread = useNewThreadHandler();74 const handleNewThread = useNewThreadHandler();
82 const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);75 const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
83 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
84 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);
85 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);76 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
86 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);77 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
87 const timestampFormat = useClientSettings((s) => s.timestampFormat);78 const timestampFormat = useClientSettings((s) => s.timestampFormat);
...@@ -127,17 +118,7 @@ export function useThreadActionMenu(input: {...@@ -127,17 +118,7 @@ export function useThreadActionMenu(input: {
127 const items = buildThreadActionMenuItems({118 const items = buildThreadActionMenuItems({
128 branch: thread.branch ?? null,119 branch: thread.branch ?? null,
129 isPinned: thread.pinnedAt != null,120 isPinned: thread.pinnedAt != null,
130 isSettled:121 isSettled: supports.settlement && thread.settledOverride === "settled",
131 supports.settlement &&
132 effectiveSettled(thread, {
133 // Minute-quantized like useNowMinute, so this classification
134 // can never disagree with the sidebar partition or ChatView's
135 // parked-thread banner within the same minute.
136 now: `${now.toISOString().slice(0, 16)}:00.000Z`,
137 autoSettleAfterDays,
138 autoSettleOnMerge,
139 changeRequest,
140 }),
141 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),122 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
142 canSnoozeNow: canSnooze(thread, { now: now.toISOString() }),123 canSnoozeNow: canSnooze(thread, { now: now.toISOString() }),
143 isRegeneratingTitle,124 isRegeneratingTitle,
...@@ -311,9 +292,6 @@ export function useThreadActionMenu(input: {...@@ -311,9 +292,6 @@ export function useThreadActionMenu(input: {
311 },292 },
312 [293 [
313 archiveThread,294 archiveThread,
314 autoSettleAfterDays,
315 autoSettleOnMerge,
316 changeRequest,
317 confirmThreadArchive,295 confirmThreadArchive,
318 confirmThreadDelete,296 confirmThreadDelete,
319 confirmAndUnpinThread,297 confirmAndUnpinThread,
apps/web/src/hooks/useThreadActions.ts+1-26
...@@ -5,7 +5,7 @@ import {...@@ -5,7 +5,7 @@ import {
5 scopedThreadKey,5 scopedThreadKey,
6} from "@t3tools/client-runtime/environment";6} from "@t3tools/client-runtime/environment";
7import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";7import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
8import { canSettle, canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";8import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
9import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts";9import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
10import * as Cause from "effect/Cause";10import * as Cause from "effect/Cause";
11import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
...@@ -64,18 +64,6 @@ export class ThreadSettlementUnsupportedError extends Schema.TaggedErrorClass<Th...@@ -64,18 +64,6 @@ export class ThreadSettlementUnsupportedError extends Schema.TaggedErrorClass<Th
64 }64 }
65}65}
6666
67export class ThreadSettleBlockedError extends Schema.TaggedErrorClass<ThreadSettleBlockedError>()(
68 "ThreadSettleBlockedError",
69 {
70 environmentId: EnvironmentId,
71 threadId: ThreadId,
72 },
73) {
74 override get message(): string {
75 return "This thread still needs attention. Resolve or interrupt it first, then try again.";
76 }
77}
78
79export class ThreadSnoozeUnsupportedError extends Schema.TaggedErrorClass<ThreadSnoozeUnsupportedError>()(67export class ThreadSnoozeUnsupportedError extends Schema.TaggedErrorClass<ThreadSnoozeUnsupportedError>()(
80 "ThreadSnoozeUnsupportedError",68 "ThreadSnoozeUnsupportedError",
81 {69 {
...@@ -506,19 +494,6 @@ export function useThreadActions() {...@@ -506,19 +494,6 @@ export function useThreadActions() {
506 );494 );
507 }495 }
508 const resolved = resolveThreadTarget(target);496 const resolved = resolveThreadTarget(target);
509 // Settle may only target what effectiveSettled could classify as
510 // settled: not starting/running sessions, not threads waiting on
511 // approvals or user input. Anything else would hide live work.
512 if (resolved && !canSettle(resolved.thread, { now: new Date().toISOString() })) {
513 return AsyncResult.failure(
514 Cause.fail(
515 new ThreadSettleBlockedError({
516 environmentId: resolved.threadRef.environmentId,
517 threadId: resolved.threadRef.threadId,
518 }),
519 ),
520 );
521 }
522 const wokeAt = resolved497 const wokeAt = resolved
523 ? threadWokeAt(resolved.thread, { now: new Date().toISOString() })498 ? threadWokeAt(resolved.thread, { now: new Date().toISOString() })
524 : null;499 : null;
docs/internals/overview.md+15-3
...@@ -88,17 +88,28 @@ A turn is complete when its session leaves `running` status, projected by...@@ -88,17 +88,28 @@ A turn is complete when its session leaves `running` status, projected by
88`settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later88`settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later
89does not define turn end.89does not define turn end.
9090
91Thread settlement is server-owned. Per-environment settings control PR and inactivity settlement.
92[`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and
93once per minute, including when no client is connected. It dispatches the guarded internal
94`thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic
95settlement excludes live background work and requires a comparable PR timestamp for immediate PR
96settlement. The command also rejects any later event for its thread after the reactor's snapshot.
97Clients render the persisted settlement state and do not derive settlement from PR or inactivity
98state. A committed `thread.settled` event also lets `ProviderCommandReactor` stop an idle provider
99session.
100
91## Drainable workers101## Drainable workers
92102
93Follow-up work runs asynchronously in queue-backed workers built on [`DrainableWorker`][worker]:103Follow-up work runs asynchronously in queue-backed workers built on [`DrainableWorker`][worker]:
94[`ProviderRuntimeIngestion`][ingest] normalizes provider runtime streams into orchestration commands,104[`ProviderRuntimeIngestion`][ingest] normalizes provider runtime streams into orchestration commands,
95[`ProviderCommandReactor`][cmd] dispatches provider calls in response to intent events, and105[`ProviderCommandReactor`][cmd] dispatches provider calls in response to intent events,
96[`CheckpointReactor`][checkpoint] captures and reverts workspace checkpoints.106[`CheckpointReactor`][checkpoint] captures and reverts workspace checkpoints, and
107[`ThreadSettlementReactor`][settlement] evaluates server-owned automatic settlement rules.
97108
98`DrainableWorker` pairs a transactional queue with a transactional count of outstanding items.109`DrainableWorker` pairs a transactional queue with a transactional count of outstanding items.
99`enqueue` atomically offers and increments; processing always decrements. `drain` retries until the110`enqueue` atomically offers and increments; processing always decrements. `drain` retries until the
100count reaches zero, so a test can await "queue empty and current item finished" instead of sleeping.111count reaches zero, so a test can await "queue empty and current item finished" instead of sleeping.
101Each of the three services exposes `drain` for exactly this.112Each of these four services exposes `drain` for exactly this.
102113
103Runtime receipts are a test-only mechanism. `RuntimeReceiptBusLive` in114Runtime receipts are a test-only mechanism. `RuntimeReceiptBusLive` in
104[`RuntimeReceiptBus.ts`][receipts] publishes nothing; only the test layer is PubSub-backed. Do not115[`RuntimeReceiptBus.ts`][receipts] publishes nothing; only the test layer is PubSub-backed. Do not
...@@ -150,5 +161,6 @@ already dispatch....@@ -150,5 +161,6 @@ already dispatch.
150[ingest]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts161[ingest]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
151[cmd]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts162[cmd]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
152[checkpoint]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts163[checkpoint]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts
164[settlement]: ../../apps/server/src/orchestration/ThreadSettlementReactor.ts
153[receipts]: ../../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts165[receipts]: ../../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts
154[drivers]: ../../apps/server/src/provider/builtInDrivers.ts166[drivers]: ../../apps/server/src/provider/builtInDrivers.ts
docs/user/thread-sidebar.md+11
...@@ -10,6 +10,17 @@ confirmation applies to the sidebar controls, thread menus, and the `mod+shift+p...@@ -10,6 +10,17 @@ confirmation applies to the sidebar controls, thread menus, and the `mod+shift+p
10Pinned threads still move to **Settled** when they become inactive. They also move when their pull10Pinned threads still move to **Settled** when they become inactive. They also move when their pull
11request merges if **Auto-settle merged threads** is enabled.11request merges if **Auto-settle merged threads** is enabled.
1212
13Each environment owns its automatic settlement settings. The server checks them even when no web,
14desktop, or mobile client is connected. By default, it settles threads after three days without
15activity and when their pull request merges. An eligible idle thread also settles when its pull
16request closes. An open pull request blocks inactivity settlement. Active work, pending input, and
17live background work keep the thread active. T3 Code settles from a closed or merged pull request
18only when its timestamp is not older than the user's latest activity. If that timestamp is not
19available, the inactivity rule still applies. A manual un-settle also keeps the thread active.
20Change these rules in **Settings > General** for the environment. A settings change affects future
21settlement and does not reopen a settled thread. Settings saved by older clients on one device no
22longer control this behavior.
23
13When you un-settle a thread, it returns to the top of the active list so you can find it right24When you un-settle a thread, it returns to the top of the active list so you can find it right
14away. Its timestamps do not change. Other threads keep their positions.25away. Its timestamps do not change. Other threads keep their positions.
1526
packages/client-runtime/src/state/threadSettled.test.ts deleted-587
...@@ -1,587 +0,0 @@
1import {
2 ProjectId,
3 ProviderInstanceId,
4 ThreadId,
5 TurnId,
6 type OrchestrationThreadShell,
7} from "@t3tools/contracts";
8import { describe, expect, it } from "vite-plus/test";
9
10import {
11 canSettle,
12 changeRequestAutoSettles,
13 effectiveSettled,
14 hasQueuedTurnStart,
15 threadLastActivityAt,
16 type ChangeRequestStateLike,
17} from "./threadSettled.ts";
18
19const NOW = "2026-04-10T00:00:00.000Z";
20const FRESH = "2026-04-09T00:00:00.000Z";
21const STALE = "2026-04-06T23:59:59.999Z";
22
23describe("changeRequestAutoSettles", () => {
24 it.each([
25 ["open", true, false],
26 ["merged", true, true],
27 ["merged", false, false],
28 ["closed", false, true],
29 [null, false, false],
30 ] as const)("state=%s autoSettleOnMerge=%s returns %s", (state, autoSettleOnMerge, expected) => {
31 expect(changeRequestAutoSettles(state === null ? null : { state }, { autoSettleOnMerge })).toBe(
32 expected,
33 );
34 });
35
36 const THREAD_CREATED_AT = "2026-04-01T00:00:00.000Z";
37 const idleThread = {
38 createdAt: THREAD_CREATED_AT,
39 latestUserMessageAt: null,
40 latestTurn: null,
41 };
42
43 it("ignores a terminal change request last touched before the thread existed", () => {
44 for (const state of ["merged", "closed"] as const) {
45 expect(
46 changeRequestAutoSettles(
47 { state, updatedAt: "2026-03-31T23:59:59.999Z" },
48 { thread: idleThread },
49 ),
50 ).toBe(false);
51 }
52 });
53
54 it("settles on a terminal change request touched at or after the thread's latest event", () => {
55 for (const updatedAt of [THREAD_CREATED_AT, "2026-04-02T00:00:00.000Z"]) {
56 expect(changeRequestAutoSettles({ state: "merged", updatedAt }, { thread: idleThread })).toBe(
57 true,
58 );
59 }
60 });
61
62 it("never re-settles a thread revived after the merge", () => {
63 // Settling on a merge happens once: a user message newer than the PR's
64 // last activity means the conversation outlived the PR.
65 const revived = {
66 createdAt: THREAD_CREATED_AT,
67 latestUserMessageAt: "2026-04-05T00:00:00.000Z",
68 latestTurn: null,
69 };
70 expect(
71 changeRequestAutoSettles(
72 { state: "merged", updatedAt: "2026-04-03T00:00:00.000Z" },
73 { thread: revived },
74 ),
75 ).toBe(false);
76 // A merge landing after the revival still settles.
77 expect(
78 changeRequestAutoSettles(
79 { state: "merged", updatedAt: "2026-04-06T00:00:00.000Z" },
80 { thread: revived },
81 ),
82 ).toBe(true);
83 });
84
85 it("still settles when the merge lands during an in-flight turn", () => {
86 // Anchor is user-initiated activity only: the agent finishing a turn
87 // after the merge must not block the settle the merge earned.
88 const midTurnMerge = {
89 createdAt: THREAD_CREATED_AT,
90 latestUserMessageAt: "2026-04-02T00:00:00.000Z",
91 latestTurn: {
92 turnId: TurnId.make("turn-mid"),
93 state: "completed" as const,
94 requestedAt: "2026-04-02T00:00:00.000Z",
95 startedAt: "2026-04-02T00:00:05.000Z",
96 completedAt: "2026-04-02T00:20:00.000Z",
97 assistantMessageId: null,
98 },
99 };
100 expect(
101 changeRequestAutoSettles(
102 { state: "merged", updatedAt: "2026-04-02T00:10:00.000Z" },
103 { thread: midTurnMerge },
104 ),
105 ).toBe(true);
106 });
107
108 it("falls back to settling when either timestamp is missing or malformed", () => {
109 expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(true);
110 expect(
111 changeRequestAutoSettles({ state: "merged", updatedAt: null }, { thread: idleThread }),
112 ).toBe(true);
113 expect(
114 changeRequestAutoSettles({ state: "merged", updatedAt: "2026-03-01T00:00:00.000Z" }, {}),
115 ).toBe(true);
116 expect(
117 changeRequestAutoSettles(
118 { state: "merged", updatedAt: "not-a-date" },
119 { thread: idleThread },
120 ),
121 ).toBe(true);
122 });
123});
124
125function makeShell(input: {
126 readonly settledOverride?: "settled" | "active" | null;
127 readonly activityAt: string | null;
128 readonly sessionStatus?: "starting" | "running";
129 readonly pending?: "approval" | "user-input";
130}): OrchestrationThreadShell {
131 const threadId = ThreadId.make("thread-1");
132 return {
133 id: threadId,
134 projectId: ProjectId.make("project-1"),
135 title: "Thread",
136 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
137 runtimeMode: "full-access",
138 interactionMode: "default",
139 branch: null,
140 worktreePath: null,
141 latestTurn:
142 input.activityAt === null
143 ? null
144 : {
145 turnId: TurnId.make("turn-1"),
146 state: "completed",
147 requestedAt: input.activityAt,
148 startedAt: null,
149 completedAt: null,
150 assistantMessageId: null,
151 },
152 createdAt: "2026-04-01T00:00:00.000Z",
153 updatedAt: NOW,
154 archivedAt: null,
155 settledOverride: input.settledOverride ?? null,
156 settledAt: input.settledOverride === "settled" ? NOW : null,
157 session:
158 input.sessionStatus === undefined
159 ? null
160 : {
161 threadId,
162 status: input.sessionStatus,
163 providerName: "Codex",
164 runtimeMode: "full-access",
165 activeTurnId: null,
166 lastError: null,
167 updatedAt: NOW,
168 },
169 latestUserMessageAt: null,
170 hasPendingApprovals: input.pending === "approval",
171 hasPendingUserInput: input.pending === "user-input",
172 hasActionableProposedPlan: false,
173 };
174}
175
176describe("threadLastActivityAt", () => {
177 it("returns the latest real user or turn activity and ignores thread/session updates", () => {
178 const shell = makeShell({ activityAt: null, sessionStatus: "running" });
179 const withActivity: OrchestrationThreadShell = {
180 ...shell,
181 latestUserMessageAt: "2026-04-04T00:00:00.000Z",
182 latestTurn: {
183 turnId: TurnId.make("turn-1"),
184 state: "completed",
185 requestedAt: "2026-04-03T00:00:00.000Z",
186 startedAt: "2026-04-05T00:00:00.000Z",
187 completedAt: "2026-04-06T00:00:00.000Z",
188 assistantMessageId: null,
189 },
190 };
191
192 expect(threadLastActivityAt(withActivity)).toBe("2026-04-06T00:00:00.000Z");
193 expect(threadLastActivityAt(shell)).toBeNull();
194 });
195});
196
197describe("effectiveSettled", () => {
198 const overrideCases = [null, "settled", "active"] as const;
199 const changeRequestStates = [undefined, "open", "merged"] as const;
200 const inactivityCases = [
201 ["fresh", FRESH],
202 ["stale", STALE],
203 ["no-activity", null],
204 ] as const;
205 const runningCases = [false, true] as const;
206 const pendingCases = [undefined, "approval", "user-input"] as const;
207 const truthTable = overrideCases.flatMap((settledOverride) =>
208 changeRequestStates.flatMap((changeRequestState) =>
209 inactivityCases.flatMap(([inactivity, activityAt]) =>
210 runningCases.flatMap((running) =>
211 pendingCases.map((pending) => ({
212 settledOverride,
213 changeRequestState,
214 inactivity,
215 activityAt,
216 running,
217 pending,
218 // Settled iff nothing blocks (pending work / live session) AND
219 // the override says settled, or (with no override) a merged PR
220 // or staleness auto-settles. The "active" pin suppresses both
221 // auto signals, and an open PR suppresses the inactivity path:
222 // a thread with a PR out for review is never done, however quiet.
223 expected:
224 pending === undefined &&
225 !running &&
226 (settledOverride === "settled" ||
227 (settledOverride === null &&
228 (changeRequestState === "merged" ||
229 (changeRequestState !== "open" && inactivity === "stale")))),
230 })),
231 ),
232 ),
233 ),
234 );
235
236 it.each(truthTable)(
237 "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending",
238 ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => {
239 const shell = makeShell({
240 settledOverride,
241 activityAt,
242 ...(running ? { sessionStatus: "running" as const } : {}),
243 ...(pending === undefined ? {} : { pending }),
244 });
245 const changeRequestOptions =
246 changeRequestState === undefined
247 ? {}
248 : { changeRequest: { state: changeRequestState as ChangeRequestStateLike } };
249
250 expect(
251 effectiveSettled(shell, {
252 now: NOW,
253 autoSettleAfterDays: 3,
254 ...changeRequestOptions,
255 }),
256 ).toBe(expected);
257 },
258 );
259
260 it("treats closed change requests like merged ones", () => {
261 const shell = makeShell({ activityAt: null });
262 expect(
263 effectiveSettled(shell, {
264 now: NOW,
265 autoSettleAfterDays: null,
266 changeRequest: { state: "closed" },
267 }),
268 ).toBe(true);
269 });
270
271 it("settles immediately when a change request merges or closes", () => {
272 const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });
273 for (const changeRequestState of ["merged", "closed"] as const) {
274 expect(
275 effectiveSettled(recentlyActive, {
276 now: NOW,
277 autoSettleAfterDays: null,
278 changeRequest: { state: changeRequestState },
279 }),
280 ).toBe(true);
281 }
282 });
283
284 it("ignores a change request that merged before the thread's latest event", () => {
285 // A new thread started at a worktree root inherits the branch's old
286 // merged PR, and a revived thread outlives its merge; neither settles
287 // the live conversation.
288 const fresh = makeShell({ activityAt: FRESH });
289 for (const state of ["merged", "closed"] as const) {
290 expect(
291 effectiveSettled(fresh, {
292 now: NOW,
293 autoSettleAfterDays: null,
294 changeRequest: { state, updatedAt: "2026-03-20T00:00:00.000Z" },
295 }),
296 ).toBe(false);
297 }
298 // A merge during the thread's life still settles it.
299 expect(
300 effectiveSettled(fresh, {
301 now: NOW,
302 autoSettleAfterDays: null,
303 changeRequest: { state: "merged", updatedAt: "2026-04-09T00:00:00.000Z" },
304 }),
305 ).toBe(true);
306 });
307
308 it("can keep a merged change request active", () => {
309 const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });
310 expect(
311 effectiveSettled(recentlyActive, {
312 now: NOW,
313 autoSettleAfterDays: null,
314 autoSettleOnMerge: false,
315 changeRequest: { state: "merged" },
316 }),
317 ).toBe(false);
318
319 expect(
320 effectiveSettled(recentlyActive, {
321 now: NOW,
322 autoSettleAfterDays: null,
323 autoSettleOnMerge: false,
324 changeRequest: { state: "closed" },
325 }),
326 ).toBe(true);
327 });
328
329 it("never auto-settles a stale thread with an open change request", () => {
330 const stale = makeShell({ activityAt: STALE });
331 expect(
332 effectiveSettled(stale, {
333 now: NOW,
334 autoSettleAfterDays: 3,
335 changeRequest: { state: "open" },
336 }),
337 ).toBe(false);
338 // An explicit user settle still wins: open PR only blocks the auto path.
339 const settled = makeShell({ settledOverride: "settled", activityAt: STALE });
340 expect(
341 effectiveSettled(settled, {
342 now: NOW,
343 autoSettleAfterDays: 3,
344 changeRequest: { state: "open" },
345 }),
346 ).toBe(true);
347 });
348
349 it("keeps an explicitly un-settled merged-PR thread active", () => {
350 const shell = makeShell({
351 settledOverride: "active",
352 activityAt: "2026-04-09T23:59:59.999Z",
353 });
354 expect(
355 effectiveSettled(shell, {
356 now: NOW,
357 autoSettleAfterDays: null,
358 changeRequest: { state: "merged" },
359 }),
360 ).toBe(false);
361 });
362
363 it("never settles a starting session, even with a settled override", () => {
364 const shell = makeShell({
365 settledOverride: "settled",
366 activityAt: STALE,
367 sessionStatus: "starting",
368 });
369 expect(
370 effectiveSettled(shell, {
371 now: NOW,
372 autoSettleAfterDays: 3,
373 changeRequest: { state: "merged" },
374 }),
375 ).toBe(false);
376 });
377
378 it("keeps a new turn active from queued through starting and running", () => {
379 const requestedAt = "2026-04-09T12:00:00.000Z";
380 const transitionNow = "2026-04-09T12:00:30.000Z";
381 const base = makeShell({
382 settledOverride: null,
383 activityAt: STALE,
384 });
385 const queued: OrchestrationThreadShell = {
386 ...base,
387 latestUserMessageAt: requestedAt,
388 latestTurn: null,
389 session: null,
390 };
391 const starting: OrchestrationThreadShell = {
392 ...queued,
393 session: {
394 threadId: queued.id,
395 status: "starting",
396 providerName: "Codex",
397 runtimeMode: "full-access",
398 activeTurnId: null,
399 lastError: null,
400 updatedAt: requestedAt,
401 },
402 };
403 const running: OrchestrationThreadShell = {
404 ...starting,
405 session: {
406 ...starting.session!,
407 status: "running",
408 activeTurnId: TurnId.make("turn-new"),
409 },
410 };
411
412 for (const shell of [queued, starting, running]) {
413 expect(
414 effectiveSettled(shell, {
415 now: transitionNow,
416 autoSettleAfterDays: 3,
417 changeRequest: { state: "merged" },
418 }),
419 ).toBe(false);
420 }
421 });
422
423 it("uses a strict inactivity boundary and honors a null threshold", () => {
424 const boundary = makeShell({
425 activityAt: "2026-04-07T00:00:00.000Z",
426 });
427 const stale = makeShell({ activityAt: STALE });
428
429 expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false);
430 expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false);
431 });
432});
433
434describe("hasQueuedTurnStart", () => {
435 const QUEUED_AT = "2026-04-09T12:00:00.000Z";
436 // Within the adoption grace window of the queued message.
437 const JUST_AFTER = { now: "2026-04-09T12:00:30.000Z" };
438
439 it("flags a user message no turn has picked up, within the grace window", () => {
440 const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null };
441 expect(hasQueuedTurnStart(noTurn, JUST_AFTER)).toBe(true);
442
443 const staleTurn = {
444 ...makeShell({ activityAt: FRESH }),
445 latestUserMessageAt: QUEUED_AT,
446 };
447 expect(hasQueuedTurnStart(staleTurn, JUST_AFTER)).toBe(true);
448 });
449
450 it("expires after the grace window: an unadopted message is a failed start, not queued work", () => {
451 const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null };
452 expect(hasQueuedTurnStart(noTurn, { now: "2026-04-09T12:03:00.000Z" })).toBe(false);
453 // Historical shells (e.g. from servers that never carried latestTurn)
454 // must never read as queued.
455 expect(hasQueuedTurnStart(noTurn, { now: NOW })).toBe(false);
456 });
457
458 it("clears once a turn adopts the message or the start fails", () => {
459 const adopted = {
460 ...makeShell({ activityAt: QUEUED_AT }),
461 latestUserMessageAt: QUEUED_AT,
462 };
463 expect(hasQueuedTurnStart(adopted, JUST_AFTER)).toBe(false);
464
465 const failed = makeShell({ activityAt: FRESH });
466 const failedShell = {
467 ...failed,
468 latestUserMessageAt: QUEUED_AT,
469 session: {
470 threadId: failed.id,
471 status: "error" as const,
472 providerName: "Codex",
473 runtimeMode: "full-access" as const,
474 activeTurnId: null,
475 lastError: "boom",
476 updatedAt: NOW,
477 },
478 };
479 expect(hasQueuedTurnStart(failedShell, JUST_AFTER)).toBe(false);
480 });
481
482 it("is quiet without user messages", () => {
483 expect(hasQueuedTurnStart(makeShell({ activityAt: FRESH }), JUST_AFTER)).toBe(false);
484 });
485
486 it("bounds the grace window in both directions: a future-stamped message is skew, not queued work", () => {
487 // Message timestamps originate on other devices; a clock an hour ahead
488 // must not hold the queued state for the whole skew.
489 const skewed = {
490 latestUserMessageAt: "2026-04-09T13:00:00.000Z",
491 latestTurn: null,
492 session: null,
493 };
494 expect(hasQueuedTurnStart(skewed, { now: "2026-04-09T12:00:00.000Z" })).toBe(false);
495 // A small negative age (within the grace window) still reads as queued.
496 const slightlyAhead = {
497 latestUserMessageAt: "2026-04-09T12:00:30.000Z",
498 latestTurn: null,
499 session: null,
500 };
501 expect(hasQueuedTurnStart(slightlyAhead, { now: "2026-04-09T12:00:00.000Z" })).toBe(true);
502 });
503});
504
505describe("canSettle", () => {
506 it("blocks every state effectiveSettled refuses to classify as settled", () => {
507 expect(canSettle(makeShell({ activityAt: FRESH }), { now: NOW })).toBe(true);
508 expect(
509 canSettle(makeShell({ activityAt: FRESH, sessionStatus: "starting" }), { now: NOW }),
510 ).toBe(false);
511 expect(
512 canSettle(makeShell({ activityAt: FRESH, sessionStatus: "running" }), { now: NOW }),
513 ).toBe(false);
514 expect(canSettle(makeShell({ activityAt: FRESH, pending: "approval" }), { now: NOW })).toBe(
515 false,
516 );
517 expect(canSettle(makeShell({ activityAt: FRESH, pending: "user-input" }), { now: NOW })).toBe(
518 false,
519 );
520 });
521
522 it("blocks settling a queued turn start, only within the grace window", () => {
523 const queued = {
524 ...makeShell({ activityAt: FRESH }),
525 latestUserMessageAt: "2026-04-09T12:00:00.000Z",
526 };
527 const justAfter = "2026-04-09T12:00:30.000Z";
528 expect(canSettle(queued, { now: justAfter })).toBe(false);
529 // effectiveSettled must agree: queued work never auto-settles either,
530 // even with a merged PR.
531 expect(
532 effectiveSettled(queued, {
533 now: justAfter,
534 autoSettleAfterDays: 3,
535 changeRequest: { state: "merged" },
536 }),
537 ).toBe(false);
538 // Past the window the message is a failed/stale start: settleable again.
539 expect(canSettle(queued, { now: NOW })).toBe(true);
540 });
541
542 it("lets a server-accepted settle overrule the clock-derived queued blocker", () => {
543 // The settle action ran with wall-clock `now` (past the grace window);
544 // the list partition re-evaluates with a minute-floored `now` that is
545 // still INSIDE the window. settledAt >= message time proves the server
546 // already adjudicated this exact message, so the row must not snap back
547 // to active until the coarser clock catches up.
548 const messageAt = "2026-04-09T12:00:00.000Z";
549 const flooredNow = "2026-04-09T12:01:00.000Z";
550 const base = makeShell({ settledOverride: "settled", activityAt: null });
551 const settledAfterMessage = {
552 ...base,
553 latestUserMessageAt: messageAt,
554 settledAt: "2026-04-09T12:02:10.000Z",
555 };
556 expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true);
557 expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe(
558 true,
559 );
560
561 // A message NEWER than settledAt is genuinely new work: still blocked
562 // until the server's auto-unsettle lands.
563 const messageAfterSettle = {
564 ...base,
565 latestUserMessageAt: "2026-04-09T12:03:00.000Z",
566 settledAt: "2026-04-09T12:02:10.000Z",
567 };
568 expect(
569 effectiveSettled(messageAfterSettle, {
570 now: "2026-04-09T12:03:30.000Z",
571 autoSettleAfterDays: 3,
572 }),
573 ).toBe(false);
574 });
575
576 it("agrees with effectiveSettled's blockers for explicitly settled shells", () => {
577 // Anything canSettle rejects must render as active even when the user
578 // settled it earlier.
579 const blocked = makeShell({
580 settledOverride: "settled",
581 activityAt: FRESH,
582 pending: "user-input",
583 });
584 expect(canSettle(blocked, { now: NOW })).toBe(false);
585 expect(effectiveSettled(blocked, { now: NOW, autoSettleAfterDays: 3 })).toBe(false);
586 });
587});
packages/client-runtime/src/state/threadSettled.ts+2-191
...@@ -1,100 +1,6 @@...@@ -1,100 +1,6 @@
1// @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels.1// @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels.
2import type { OrchestrationThreadShell } from "@t3tools/contracts";2import type { OrchestrationThreadShell } from "@t3tools/contracts";
33
4export type ChangeRequestStateLike = "open" | "closed" | "merged";
5
6/**
7 * The slice of a change request the settle rules need. `updatedAt` is the
8 * provider's last-activity timestamp; for a merged/closed request it bounds
9 * when the terminal state landed.
10 */
11export interface ChangeRequestSettleSource {
12 readonly state: ChangeRequestStateLike;
13 readonly updatedAt?: string | null | undefined;
14}
15
16/** What the settle rules need to know about the thread's own timeline. */
17export type ThreadActivitySource = Pick<
18 OrchestrationThreadShell,
19 "createdAt" | "latestUserMessageAt" | "latestTurn"
20>;
21
22/**
23 * Latest USER-initiated activity: messages and the turn requests they start,
24 * deliberately not the agent-side started/completed stamps. The settle-on-
25 * merge anchor uses this so a merge landing mid-turn still settles the
26 * thread when that turn finishes, while a user re-engaging after the merge
27 * blocks it for good. Falls back to creation time for untouched threads.
28 */
29function threadUserActivityAnchorAt(thread: ThreadActivitySource): string {
30 const messageAt = thread.latestUserMessageAt;
31 const requestedAt = thread.latestTurn?.requestedAt;
32 let anchor = thread.createdAt;
33 for (const candidate of [messageAt, requestedAt]) {
34 if (candidate != null && Date.parse(candidate) > Date.parse(anchor)) {
35 anchor = candidate;
36 }
37 }
38 return anchor;
39}
40
41/**
42 * Returns whether the change request settles the thread immediately. A
43 * terminal request settles the thread only while it postdates every user-
44 * initiated event in it: settling on a merge happens ONCE. A request last
45 * touched before the thread was created is inherited branch history (a new
46 * thread started at a worktree root whose PR already merged), and one older
47 * than the user's latest engagement was already adjudicated — re-engaging a
48 * thread whose PR merged is the user saying the conversation outlived the
49 * PR. Unknown timestamps keep the old always-settle behavior.
50 */
51export function changeRequestAutoSettles(
52 changeRequest: ChangeRequestSettleSource | null | undefined,
53 options: {
54 readonly autoSettleOnMerge?: boolean | undefined;
55 readonly thread?: ThreadActivitySource | null | undefined;
56 } = {},
57): boolean {
58 if (changeRequest == null) return false;
59 const terminal =
60 changeRequest.state === "closed" ||
61 (changeRequest.state === "merged" && options.autoSettleOnMerge !== false);
62 if (!terminal) return false;
63 if (changeRequest.updatedAt == null || options.thread == null) return true;
64 const updatedAtMs = Date.parse(changeRequest.updatedAt);
65 const anchorAtMs = Date.parse(threadUserActivityAnchorAt(options.thread));
66 // Malformed timestamps fall back to settling, matching servers that never
67 // report updatedAt.
68 if (Number.isNaN(updatedAtMs) || Number.isNaN(anchorAtMs)) return true;
69 return updatedAtMs >= anchorAtMs;
70}
71
72const DAY_MS = 24 * 60 * 60 * 1_000;
73
74export function threadLastActivityAt(
75 shell: Pick<OrchestrationThreadShell, "latestUserMessageAt" | "latestTurn">,
76): string | null {
77 const candidates = [
78 shell.latestUserMessageAt,
79 shell.latestTurn?.requestedAt,
80 shell.latestTurn?.startedAt,
81 shell.latestTurn?.completedAt,
82 ];
83 let latest: string | null = null;
84 let latestTimestamp = Number.NEGATIVE_INFINITY;
85
86 for (const candidate of candidates) {
87 if (candidate === null || candidate === undefined) continue;
88 const timestamp = Date.parse(candidate);
89 if (timestamp > latestTimestamp) {
90 latest = candidate;
91 latestTimestamp = timestamp;
92 }
93 }
94
95 return latest;
96}
97
98/**4/**
99 * A queued turn start lives for at most this long: session adoption takes5 * A queued turn start lives for at most this long: session adoption takes
100 * seconds, so a user message still unadopted after the grace window is a6 * seconds, so a user message still unadopted after the grace window is a
...@@ -103,6 +9,7 @@ export function threadLastActivityAt(...@@ -103,6 +9,7 @@ export function threadLastActivityAt(
103 * such threads would be permanently unsettleable.9 * such threads would be permanently unsettleable.
104 */10 */
105export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;11export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;
12const DAY_MS = 24 * 60 * 60 * 1_000;
10613
107/**14/**
108 * A user message no turn has picked up yet: the turn.start command was15 * A user message no turn has picked up yet: the turn.start command was
...@@ -137,28 +44,6 @@ export function hasQueuedTurnStart(...@@ -137,28 +44,6 @@ export function hasQueuedTurnStart(
137 );44 );
138}45}
13946
140/**
141 * A thread may be settled only when none of effectiveSettled's activity
142 * blockers hold. This is deliberately the same list: anything the partition
143 * refuses to CLASSIFY as settled must also be refused as a settle TARGET.
144 * The server enforces its own invariants; this client-side twin exists so
145 * the UI can disable/reject before a round trip.
146 */
147export function canSettle(
148 shell: Pick<
149 OrchestrationThreadShell,
150 "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestUserMessageAt" | "latestTurn"
151 >,
152 options: { readonly now: string },
153): boolean {
154 if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false;
155 if (shell.session?.status === "starting" || shell.session?.status === "running") return false;
156 // Queued work is as blocked-on-progress as a live session: settling it
157 // (or auto-settling it on a closed PR) would hide a just-requested turn.
158 if (hasQueuedTurnStart(shell, options)) return false;
159 return true;
160}
161
162/**47/**
163 * The snooze lifecycle fields plus everything needed to detect a raised48 * The snooze lifecycle fields plus everything needed to detect a raised
164 * hand. Snooze is an overlay on the active state: a snoozed thread stays49 * hand. Snooze is an overlay on the active state: a snoozed thread stays
...@@ -181,8 +66,7 @@ export type ThreadSnoozeShell = Pick<...@@ -181,8 +66,7 @@ export type ThreadSnoozeShell = Pick<
181 * the session failed, or a run completed after the snooze was set — the66 * the session failed, or a run completed after the snooze was set — the
182 * v1 taste of event-based snooze ("something happened" wakes early).67 * v1 taste of event-based snooze ("something happened" wakes early).
183 * Raising a hand never clears the server-side snooze fields; it only stops68 * Raising a hand never clears the server-side snooze fields; it only stops
184 * the thread from CLASSIFYING as snoozed, exactly like blocked work and69 * the thread from classifying as snoozed.
185 * effectiveSettled.
186 */70 */
187export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean {71export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean {
188 if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true;72 if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true;
...@@ -283,79 +167,6 @@ export function threadWokeAt(...@@ -283,79 +167,6 @@ export function threadWokeAt(
283 return wakeAtMs <= Date.parse(options.now) ? shell.snoozedUntil : null;167 return wakeAtMs <= Date.parse(options.now) ? shell.snoozedUntil : null;
284}168}
285169
286/**
287 * Settled resolution over the server-backed settled lifecycle. Activity
288 * blockers (pending approval/user-input, a live session, an unadjudicated
289 * queued turn) are checked first and hold a thread active regardless of any
290 * override. Past the blockers, the explicit user override (thread.settle /
291 * thread.unsettle commands, projected into settledOverride + settledAt)
292 * wins in both directions; without one, a thread can auto-settle on a
293 * merged PR or always on a closed PR (both only while the terminal state is
294 * the thread's latest event, see changeRequestAutoSettles), or settles on
295 * inactivity past the window.
296 * An open PR blocks the inactivity path entirely. The server
297 * un-settles on real activity (user message, session start, approval/
298 * user-input request), so an override never goes stale silently.
299 */
300export function effectiveSettled(
301 shell: OrchestrationThreadShell,
302 options: {
303 readonly now: string;
304 readonly autoSettleAfterDays: number | null;
305 readonly autoSettleOnMerge?: boolean;
306 readonly changeRequest?: ChangeRequestSettleSource | null;
307 },
308): boolean {
309 // Blocked work must remain visible even when a user explicitly settled it.
310 if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false;
311 if (shell.session?.status === "starting" || shell.session?.status === "running") return false;
312 if (hasQueuedTurnStart(shell, { now: options.now })) {
313 // The queued-turn blocker alone is forgivable: it is clock-derived, and
314 // list callers pass a coarser `now` than the settle action used. When
315 // the server already adjudicated the queued message by accepting a
316 // settle after it (settledAt stamps server accept time), trust that
317 // ruling — otherwise a settle near the grace boundary leaves the row
318 // pinned active until the caller's clock ticks over. A message NEWER
319 // than settledAt is genuinely new work and keeps the block until the
320 // server's auto-unsettle lands.
321 const serverAdjudicated =
322 shell.settledOverride === "settled" &&
323 shell.settledAt !== null &&
324 shell.latestUserMessageAt !== null &&
325 Date.parse(shell.settledAt) >= Date.parse(shell.latestUserMessageAt);
326 if (!serverAdjudicated) return false;
327 }
328 if (shell.settledOverride === "settled") return true;
329 // "active" is the explicit keep-active pin: it suppresses auto-settle
330 // until real activity clears it server-side.
331 if (shell.settledOverride === "active") return false;
332 if (
333 changeRequestAutoSettles(options.changeRequest, {
334 autoSettleOnMerge: options.autoSettleOnMerge,
335 thread: shell,
336 })
337 ) {
338 return true;
339 }
340 // An open PR is unfinished business regardless of how long the thread has
341 // been quiet: review can take days, and hiding the thread would bury the
342 // work waiting on it. A configured merge, a close, or an explicit user
343 // settle resolves it.
344 if (options.changeRequest?.state === "open") return false;
345 if (options.autoSettleAfterDays === null) return false;
346
347 const lastActivityAt = threadLastActivityAt(shell);
348 if (lastActivityAt === null) return false;
349
350 // threadLastActivityAt only returns candidates whose Date.parse beat
351 // -Infinity, so this parse is a real number; a malformed `now` yields NaN,
352 // the comparison is false, and the thread stays active (never a surprise
353 // auto-settle on bad input).
354 return (
355 Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS
356 );
357}
358
359const HOUR_MS = 60 * 60 * 1_000;170const HOUR_MS = 60 * 60 * 1_000;
360const EVENING_HOUR = 18;171const EVENING_HOUR = 18;
361const MORNING_HOUR = 9;172const MORNING_HOUR = 9;
packages/client-runtime/src/state/threadSnoozed.test.ts+60
...@@ -6,12 +6,14 @@ import { describe, expect, it } from "vite-plus/test";...@@ -6,12 +6,14 @@ import { describe, expect, it } from "vite-plus/test";
6import {6import {
7 canSnooze,7 canSnooze,
8 effectiveSnoozed,8 effectiveSnoozed,
9 hasQueuedTurnStart,
9 resolveSnoozePresets,10 resolveSnoozePresets,
10 snoozeWakeLabel,11 snoozeWakeLabel,
11 threadRaisedHandWhileSnoozed,12 threadRaisedHandWhileSnoozed,
12 threadWokeAt,13 threadWokeAt,
13 type ThreadSnoozeShell,14 type ThreadSnoozeShell,
14} from "./threadSettled.ts";15} from "./threadSettled.ts";
16import type { OrchestrationThreadShell } from "@t3tools/contracts";
1517
16const NOW = "2026-04-10T12:00:00.000Z";18const NOW = "2026-04-10T12:00:00.000Z";
17const SNOOZED_AT = "2026-04-10T09:00:00.000Z";19const SNOOZED_AT = "2026-04-10T09:00:00.000Z";
...@@ -61,6 +63,15 @@ function makeShell(input: {...@@ -61,6 +63,15 @@ function makeShell(input: {
61 };63 };
62}64}
6365
66type QueuedTurnShell = Pick<
67 OrchestrationThreadShell,
68 "latestUserMessageAt" | "latestTurn" | "session"
69>;
70
71function makeQueuedTurnShell(overrides: Partial<QueuedTurnShell> = {}): QueuedTurnShell {
72 return { latestUserMessageAt: null, latestTurn: null, session: null, ...overrides };
73}
74
64describe("effectiveSnoozed", () => {75describe("effectiveSnoozed", () => {
65 it("hides a thread whose wake time is in the future", () => {76 it("hides a thread whose wake time is in the future", () => {
66 expect(effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE }), { now: NOW })).toBe(true);77 expect(effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE }), { now: NOW })).toBe(true);
...@@ -202,6 +213,55 @@ describe("canSnooze", () => {...@@ -202,6 +213,55 @@ describe("canSnooze", () => {
202 });213 });
203});214});
204215
216describe("hasQueuedTurnStart", () => {
217 it("expires queued state after two minutes", () => {
218 const thread = makeQueuedTurnShell({
219 latestUserMessageAt: "2026-04-10T11:57:59.000Z",
220 });
221 expect(hasQueuedTurnStart(thread, { now: NOW })).toBe(false);
222 });
223
224 it("clears queued state when a turn adopts the message or the session fails", () => {
225 const messageAt = "2026-04-10T11:59:00.000Z";
226 const adopted = makeQueuedTurnShell({
227 latestUserMessageAt: messageAt,
228 latestTurn: {
229 turnId: TurnId.make("turn-adopted"),
230 state: "running",
231 requestedAt: messageAt,
232 startedAt: null,
233 completedAt: null,
234 assistantMessageId: null,
235 },
236 });
237 const failed = makeQueuedTurnShell({
238 latestUserMessageAt: messageAt,
239 session: {
240 threadId: ThreadId.make("thread-failed"),
241 status: "error",
242 providerName: "Codex",
243 runtimeMode: "full-access",
244 activeTurnId: null,
245 lastError: "failed",
246 updatedAt: NOW,
247 },
248 });
249 expect(hasQueuedTurnStart(adopted, { now: NOW })).toBe(false);
250 expect(hasQueuedTurnStart(failed, { now: NOW })).toBe(false);
251 });
252
253 it("bounds future client clock skew", () => {
254 const farAhead = makeQueuedTurnShell({
255 latestUserMessageAt: "2026-04-10T12:03:00.000Z",
256 });
257 const slightlyAhead = makeQueuedTurnShell({
258 latestUserMessageAt: "2026-04-10T12:01:00.000Z",
259 });
260 expect(hasQueuedTurnStart(farAhead, { now: NOW })).toBe(false);
261 expect(hasQueuedTurnStart(slightlyAhead, { now: NOW })).toBe(true);
262 });
263});
264
205describe("threadWokeAt", () => {265describe("threadWokeAt", () => {
206 it("is null for never-snoozed and still-snoozed threads", () => {266 it("is null for never-snoozed and still-snoozed threads", () => {
207 expect(threadWokeAt(makeShell({}), { now: NOW })).toBe(null);267 expect(threadWokeAt(makeShell({}), { now: NOW })).toBe(null);
packages/contracts/src/environment.ts+2
...@@ -63,6 +63,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -63,6 +63,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
63 pre-settlement servers, so clients treat missing as unsupported and63 pre-settlement servers, so clients treat missing as unsupported and
64 never send the commands under version skew. */64 never send the commands under version skew. */
65 threadSettlement: Schema.optionalKey(Schema.Boolean),65 threadSettlement: Schema.optionalKey(Schema.Boolean),
66 /** Server evaluates merge and inactivity settlement without a client. */
67 threadAutoSettlement: Schema.optionalKey(Schema.Boolean),
66 /** Server understands thread.snooze / thread.unsnooze commands. Same68 /** Server understands thread.snooze / thread.unsnooze commands. Same
67 version-skew contract as threadSettlement. */69 version-skew contract as threadSettlement. */
68 threadSnooze: Schema.optionalKey(Schema.Boolean),70 threadSnooze: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/orchestration.ts+8
...@@ -772,6 +772,13 @@ const ThreadSettleCommand = Schema.Struct({...@@ -772,6 +772,13 @@ const ThreadSettleCommand = Schema.Struct({
772 threadId: ThreadId,772 threadId: ThreadId,
773});773});
774774
775const ThreadAutoSettleCommand = Schema.Struct({
776 type: Schema.Literal("thread.auto-settle"),
777 commandId: CommandId,
778 threadId: ThreadId,
779 snapshotSequence: NonNegativeInt,
780});
781
775const ThreadUnsettleCommand = Schema.Struct({782const ThreadUnsettleCommand = Schema.Struct({
776 type: Schema.Literal("thread.unsettle"),783 type: Schema.Literal("thread.unsettle"),
777 commandId: CommandId,784 commandId: CommandId,
...@@ -1107,6 +1114,7 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({...@@ -1107,6 +1114,7 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({
1107});1114});
11081115
1109const InternalOrchestrationCommand = Schema.Union([1116const InternalOrchestrationCommand = Schema.Union([
1117 ThreadAutoSettleCommand,
1110 ThreadSessionSetCommand,1118 ThreadSessionSetCommand,
1111 ThreadMessageAssistantDeltaCommand,1119 ThreadMessageAssistantDeltaCommand,
1112 ThreadMessageAssistantCompleteCommand,1120 ThreadMessageAssistantCompleteCommand,
packages/contracts/src/settings.test.ts+22-17
...@@ -137,11 +137,8 @@ describe("ClientSettings environment identification", () => {...@@ -137,11 +137,8 @@ describe("ClientSettings environment identification", () => {
137});137});
138138
139describe("ClientSettings sidebar", () => {139describe("ClientSettings sidebar", () => {
140 it("defaults to the current sidebar with automatic merge and inactivity settling", () => {140 it("defaults to the current sidebar", () => {
141 const settings = decodeClientSettings({});141 expect(decodeClientSettings({}).legacySidebarEnabled).toBe(false);
142 expect(settings.legacySidebarEnabled).toBe(false);
143 expect(settings.sidebarAutoSettleAfterDays).toBe(3);
144 expect(settings.sidebarAutoSettleOnMerge).toBe(true);
145 });142 });
146143
147 it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => {144 it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => {
...@@ -166,25 +163,33 @@ describe("ClientSettings sidebar", () => {...@@ -166,25 +163,33 @@ describe("ClientSettings sidebar", () => {
166 expect(decodeClientSettingsPatch({ confirmThreadUnpin: true }).confirmThreadUnpin).toBe(true);163 expect(decodeClientSettingsPatch({ confirmThreadUnpin: true }).confirmThreadUnpin).toBe(true);
167 expect(() => decodeClientSettingsPatch({ confirmThreadUnpin: "yes" })).toThrow();164 expect(() => decodeClientSettingsPatch({ confirmThreadUnpin: "yes" })).toThrow();
168 });165 });
166});
169167
170 it("allows auto-settle by inactivity to be disabled", () => {168describe("ServerSettings thread settlement", () => {
171 expect(169 it("defaults merge settlement on and inactivity settlement to three days", () => {
172 decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays,170 const settings = decodeServerSettings({});
173 ).toBeNull();171 expect(settings.sidebarAutoSettleAfterDays).toBe(3);
172 expect(settings.sidebarAutoSettleOnMerge).toBe(true);
174 });173 });
175174
176 it("allows auto-settle on merge to be disabled", () => {175 it("allows both automatic rules to be disabled", () => {
177 expect(decodeClientSettings({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge).toBe(
178 false,
179 );
180 expect(176 expect(
181 decodeClientSettingsPatch({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge,177 decodeServerSettings({
182 ).toBe(false);178 sidebarAutoSettleAfterDays: null,
179 sidebarAutoSettleOnMerge: false,
180 }),
181 ).toMatchObject({ sidebarAutoSettleAfterDays: null, sidebarAutoSettleOnMerge: false });
182 expect(
183 decodeServerSettingsPatch({
184 sidebarAutoSettleAfterDays: null,
185 sidebarAutoSettleOnMerge: false,
186 }),
187 ).toMatchObject({ sidebarAutoSettleAfterDays: null, sidebarAutoSettleOnMerge: false });
183 });188 });
184189
185 it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => {190 it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => {
186 expect(() => decodeClientSettings({ sidebarAutoSettleAfterDays: value })).toThrow();191 expect(() => decodeServerSettings({ sidebarAutoSettleAfterDays: value })).toThrow();
187 expect(() => decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow();192 expect(() => decodeServerSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow();
188 });193 });
189});194});
190195
packages/contracts/src/settings.ts+6-6
...@@ -253,10 +253,6 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -253,10 +253,6 @@ export const ClientSettingsSchema = Schema.Struct({
253 // old keys, so everyone, including prior beta opt-outs, resets to the new253 // old keys, so everyone, including prior beta opt-outs, resets to the new
254 // default sidebar.254 // default sidebar.
255 legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),255 legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
256 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
257 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
258 ),
259 sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
260 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(256 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
261 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),257 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
262 ),258 ),
...@@ -658,6 +654,10 @@ export const ServerSettings = Schema.Struct({...@@ -658,6 +654,10 @@ export const ServerSettings = Schema.Struct({
658 * between a desktop window and a phone attached to the same server.654 * between a desktop window and a phone attached to the same server.
659 */655 */
660 enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),656 enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
657 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
658 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
659 ),
660 sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
661 backgroundActivity: BackgroundActivitySettings,661 backgroundActivity: BackgroundActivitySettings,
662 // Legacy flat fields retained for old settings files and old clients. New662 // Legacy flat fields retained for old settings files and old clients. New
663 // consumers should resolve `backgroundActivity` instead.663 // consumers should resolve `backgroundActivity` instead.
...@@ -883,6 +883,8 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -883,6 +883,8 @@ export const ServerSettingsPatch = Schema.Struct({
883 enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean),883 enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean),
884 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),884 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
885 enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),885 enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),
886 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
887 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
886 backgroundActivity: Schema.optionalKey(888 backgroundActivity: Schema.optionalKey(
887 Schema.Struct({889 Schema.Struct({
888 schemaVersion: Schema.optionalKey(Schema.Literal(1)),890 schemaVersion: Schema.optionalKey(Schema.Literal(1)),
...@@ -976,8 +978,6 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -976,8 +978,6 @@ export const ClientSettingsPatch = Schema.Struct({
976 planModeEnabled: Schema.optionalKey(Schema.Boolean),978 planModeEnabled: Schema.optionalKey(Schema.Boolean),
977 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),979 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
978 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),980 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
979 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
980 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
981 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),981 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
982 sidebarProjectGroupingOverrides: Schema.optionalKey(982 sidebarProjectGroupingOverrides: Schema.optionalKey(
983 Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),983 Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),