| author | |
| committer | |
| log | f32f9a2f41342bf8a1a109d6ebe9b70044c5311b |
| tree | 653a83aa72dc2b3f76d0f95d9d61e73f6cd2538e |
| parent | 7e4ce3bbb16c3cfa1ea756de917d8f9398e5999e |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 59 | import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; | 58 | import { 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), so | 483 | // Settled threads stay in the live shell stream (settled ≠ archived), so |
| 488 | // the partition works directly off live shells — no snapshot merging or | 484 | // 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-settle | 552 | // 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 the | 554 | // 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 woken | 555 | // 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 hours | 561 | // 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)) ?? null | 829 | 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 @@ |
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 2 | import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled"; | 2 | import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; |
| 3 | import * as Cause from "effect/Cause"; | 3 | import * as Cause from "effect/Cause"; |
| 4 | import * as Haptics from "expo-haptics"; | 4 | import * as Haptics from "expo-haptics"; |
| 5 | import { useCallback, useRef } from "react"; | 5 | import { 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 a | 121 | // 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"; |
| 34 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | 34 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; |
| 35 | import { runtime } from "../../lib/runtime"; | 35 | import { runtime } from "../../lib/runtime"; |
| 36 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | 36 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 37 | import { serverEnvironment } from "../../state/server"; | ||
| 38 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 39 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 37 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | 40 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| 38 | import { | 41 | import { |
| 39 | type AppUpdateCheckState, | 42 | type AppUpdateCheckState, |
| ... | @@ -527,26 +530,54 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -527,26 +530,54 @@ function ConfiguredSettingsRouteScreen() { |
| 527 | } | 530 | } |
| 528 | 531 | ||
| 529 | function GeneralSettingsSection() { | 532 | function 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; | ||
| 535 | 537 | ||
| 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 | <SettingsSwitchRow | 541 | {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 | } |
| 549 | 552 | ||
| 553 | function 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 is | 582 | * Device-local legacy toggles. Mobile has no client-settings sync, so this is |
| 552 | * the counterpart of web's Settings → General → Legacy features backed by | 583 | * 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 { |
| 9 | import { LegendList } from "@legendapp/list/react-native"; | 9 | import { LegendList } from "@legendapp/list/react-native"; |
| 10 | import type { MenuAction } from "@react-native-menu/menu"; | 10 | import type { MenuAction } from "@react-native-menu/menu"; |
| 11 | import { useAtomValue } from "@effect/atom-react"; | 11 | import { useAtomValue } from "@effect/atom-react"; |
| 12 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 13 | import type { EnvironmentId } from "@t3tools/contracts"; | 12 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 14 | import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; | 13 | import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; |
| 15 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 14 | import { 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"; |
| 28 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 27 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 29 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; | 28 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; |
| 30 | import { useProjects, useThreadShells } from "../../state/entities"; | 29 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 31 | import { mobilePreferencesAtom } from "../../state/preferences"; | ||
| 32 | import { useThreadSearch } from "../../state/queries"; | 30 | import { useThreadSearch } from "../../state/queries"; |
| 33 | import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; | 31 | import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; |
| 34 | import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; | 32 | import { 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"; |
| 88 | 85 | ||
| ... | @@ -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( |
| 365 | 358 | ||
| 366 | // Thread List v2 (beta) support — same model as the compact Home list | 359 | // 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 filter | 361 | // 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 actually | 383 | // 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 the | 385 | // 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 woken | 386 | // 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 old | 391 | // 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"; |
| 23 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 23 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 24 | import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; | 24 | import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; |
| 25 | import { | 25 | import { |
| 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"; |
| 34 | import { ThreadSearchMatchExcerpt } from "./thread-search-match"; | 32 | import { 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; |
| 405 | 396 | ||
| 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]); | ||
| 419 | 398 | ||
| 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]); |
| 455 | 434 | ||
| 456 | // Swipe: the v2 primary action is the lifecycle transition. Every settled | 435 | // Swipe: the v2 primary action is the lifecycle transition. Un-settling a |
| 457 | // row can un-settle — explicit settles clear the override, auto-settled | 436 | // 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.snoozeSupported | 439 | 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"; |
| 16 | import { | 16 | import { |
| 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 | }; |
| 63 | 62 | ||
| 64 | describe("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 | |||
| 100 | describe("resolveThreadListV2SnoozeMenuSelection", () => { | 63 | describe("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 | }); |
| 320 | 283 | ||
| 321 | describe("buildThreadListV2Items", () => { | 284 | describe("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 | }); |
| 369 | 299 | ||
| ... | @@ -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 | }); |
| 373 | 303 | ||
| 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 | }); |
| 441 | 354 | ||
| 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 | }); |
| 506 | 367 | ||
| 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 | }); |
| 567 | 426 | ||
| 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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 9 | import type { | 8 | import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; |
| 10 | ChangeRequestSettleSource, | ||
| 11 | SnoozePreset, | ||
| 12 | } from "@t3tools/client-runtime/state/thread-settled"; | ||
| 13 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 9 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 14 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; | 10 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 15 | import { | 11 | import { |
| 16 | activeThreadAnchorTimestampMs, | 12 | activeThreadAnchorTimestampMs, |
| 17 | sortPinnedThreadsByOrderKey, | 13 | sortPinnedThreadsByOrderKey, |
| 18 | } from "@t3tools/client-runtime/state/thread-sort"; | 14 | } from "@t3tools/client-runtime/state/thread-sort"; |
| 19 | import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts"; | 15 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 20 | 16 | ||
| 21 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 17 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 22 | 18 | ||
| ... | @@ -33,35 +29,6 @@ export { snoozeWakeLabel }; | ... | @@ -33,35 +29,6 @@ export { snoozeWakeLabel }; |
| 33 | export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; | 29 | export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; |
| 34 | export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; | 30 | export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; |
| 35 | 31 | ||
| 36 | export interface ThreadListV2ChangeRequestState extends ChangeRequestSettleSource { | ||
| 37 | readonly linkedPullRequestKey?: string | null; | ||
| 38 | } | ||
| 39 | |||
| 40 | function 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. */ | ||
| 52 | export 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 | |||
| 65 | export function resolveThreadListV2SnoozeMenuSelection(input: { | 32 | export 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: { |
| 347 | 314 | ||
| 348 | /** | 315 | /** |
| 349 | * Partitions visible threads into the active card block (creation order) and | 316 | * Partitions visible threads into the active card block (creation order) and |
| 350 | * the settled recency tail, matching the web v2 list. Mobile stores these | 317 | * the settled recency tail, matching the web v2 list. |
| 351 | * auto-settle preferences per device. | ||
| 352 | */ | 318 | */ |
| 353 | export function buildThreadListV2Items(input: { | 319 | export 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 on | 328 | /** Environments whose server supports thread.settle/unsettle. Threads on |
| 365 | other environments never classify as settled — the user could neither | 329 | 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. Same | 332 | /** 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.projectRefs | 349 | 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 them | 359 | // 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 has | 35 | * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has |
| 37 | * no client-settings sync, so the legacy grouped thread list is opted into | 36 | * 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"; |
| 66 | import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; | 66 | import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; |
| 67 | import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts"; | ||
| 67 | import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; | 68 | import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; |
| 68 | import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; | 69 | import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 69 | import { | 70 | import { |
| ... | @@ -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?: { |
| 629 | 630 | ||
| 630 | const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings); | 631 | const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings); |
| 631 | 632 | ||
| 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 | ); |
| 957 | 977 | ||
| 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 | ); |
| 976 | 1020 | ||
| 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 the | 950 | // Cache keys are NUL-joined. Automatic settlement validates repository URLs |
| 942 | // segments can contain a NUL byte, and refs are never empty, so "" decodes | 951 | // 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 waits | 971 | // 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 an | 1022 | // 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 | }); |
| 1220 | 1245 | ||
| 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.upstreamRef | 1273 | 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 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | ||
| 1 | import * as SchemaIssue from "effect/SchemaIssue"; | 2 | import * as SchemaIssue from "effect/SchemaIssue"; |
| 2 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 3 | 4 | ||
| ... | @@ -40,6 +41,24 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass< | ... | @@ -40,6 +41,24 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass< |
| 40 | } | 41 | } |
| 41 | } | 42 | } |
| 42 | 43 | ||
| 44 | export 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 | |||
| 55 | export const OrchestrationCommandRejection = Schema.Union([ | ||
| 56 | OrchestrationCommandInvariantError, | ||
| 57 | OrchestrationThreadSettleBlockedError, | ||
| 58 | ]); | ||
| 59 | export type OrchestrationCommandRejection = typeof OrchestrationCommandRejection.Type; | ||
| 60 | export const isOrchestrationCommandRejection = Schema.is(OrchestrationCommandRejection); | ||
| 61 | |||
| 43 | export class OrchestrationCommandPreviouslyRejectedError extends Schema.TaggedErrorClass<OrchestrationCommandPreviouslyRejectedError>()( | 62 | export 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< |
| 96 | 115 | ||
| 97 | export type OrchestrationDispatchError = | 116 | export type OrchestrationDispatchError = |
| 98 | | ProjectionRepositoryError | 117 | | ProjectionRepositoryError |
| 99 | | OrchestrationCommandInvariantError | 118 | | OrchestrationCommandRejection |
| 100 | | OrchestrationCommandIdConflictError | 119 | | OrchestrationCommandIdConflictError |
| 101 | | OrchestrationCommandPreviouslyRejectedError | 120 | | OrchestrationCommandPreviouslyRejectedError |
| 102 | | OrchestrationProjectorDecodeError | 121 | | 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"; |
| 12 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 12 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 13 | import { it as effectIt } from "@effect/vitest"; | ||
| 13 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| 14 | import * as Layer from "effect/Layer"; | 15 | import * as Layer from "effect/Layer"; |
| 15 | import * as ManagedRuntime from "effect/ManagedRuntime"; | 16 | import * 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"; |
| 17 | import * as Option from "effect/Option"; | 18 | import * as Option from "effect/Option"; |
| 18 | import * as Queue from "effect/Queue"; | 19 | import * as Queue from "effect/Queue"; |
| 19 | import * as Stream from "effect/Stream"; | 20 | import * as Stream from "effect/Stream"; |
| 21 | import { TestClock } from "effect/testing"; | ||
| 20 | import { describe, expect, it } from "vite-plus/test"; | 22 | import { describe, expect, it } from "vite-plus/test"; |
| 21 | 23 | ||
| 22 | import { PersistenceSqlError } from "../../persistence/Errors.ts"; | 24 | import { PersistenceSqlError } from "../../persistence/Errors.ts"; |
| 23 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; | 25 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; |
| 26 | import * as OrchestrationCommandReceipts from "../../persistence/Services/OrchestrationCommandReceipts.ts"; | ||
| 24 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; | 27 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; |
| 25 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | 28 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| 26 | import { | 29 | import { |
| ... | @@ -46,27 +49,30 @@ const asMessageId = (value: string): MessageId => MessageId.make(value); | ... | @@ -46,27 +49,30 @@ const asMessageId = (value: string): MessageId => MessageId.make(value); |
| 46 | const asTurnId = (value: string): TurnId => TurnId.make(value); | 49 | const asTurnId = (value: string): TurnId => TurnId.make(value); |
| 47 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); | 50 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); |
| 48 | 51 | ||
| 49 | async function createOrchestrationSystem() { | 52 | function 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 | |||
| 74 | async 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 | }); |
| 245 | 252 | ||
| 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"; |
| 33 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; | 33 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| 34 | import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; | 34 | import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; |
| 35 | import { | 35 | import { |
| 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"; |
| 43 | import { createEmptyReadModel, projectEvent } from "../projector.ts"; | 44 | import { createEmptyReadModel, projectEvent } from "../projector.ts"; |
| 44 | import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; | 45 | import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; |
| 45 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; | 46 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; |
| 47 | import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; | ||
| 46 | import { | 48 | import { |
| 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 | ); |
| 53 | const isOrchestrationCommandIdConflictError = Schema.is(OrchestrationCommandIdConflictError); | 55 | const isOrchestrationCommandIdConflictError = Schema.is(OrchestrationCommandIdConflictError); |
| 54 | const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvariantError); | ||
| 55 | 56 | ||
| 56 | interface CommandEnvelope { | 57 | interface 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; |
| 90 | 92 | ||
| 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 | } |
| 171 | 173 | ||
| 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 | ? cause | 205 | ? 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 | ); |
| 309 | 335 | ||
| 310 | if (isOrchestrationCommandInvariantError(error)) { | 336 | if (isOrchestrationCommandRejection(error)) { |
| 311 | yield* commandReceiptRepository | 337 | 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"; |
| 9 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; | 9 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 10 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; | 10 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 11 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; | 11 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; |
| 12 | import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; | ||
| 12 | import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; | 13 | import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; |
| 13 | import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; | 14 | import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; |
| 14 | import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; | 15 | import * 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 | }); |
| 25 | 26 | ||
| 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[] = []; |
| 28 | 29 | ||
| 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 | ]); |
| 90 | 101 |
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"; |
| 9 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; | 9 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 10 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; | 10 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 11 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; | 11 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; |
| 12 | import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; | ||
| 12 | import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; | 13 | import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; |
| 13 | 14 | ||
| 14 | export const makeOrchestrationReactor = Effect.gen(function* () { | 15 | export 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; |
| 20 | 22 | ||
| 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 | }); |
| 28 | 31 |
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 | >; |
| 67 | 68 | ||
| ... | @@ -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 | }); |
| 1495 | 1514 | ||
| ... | @@ -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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { | ||
| 3 | ProviderInstanceId, | ||
| 4 | ThreadId, | ||
| 5 | ProjectId, | ||
| 6 | TurnId, | ||
| 7 | type OrchestrationThreadShell, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { shouldAutoSettleThread } from "./ThreadSettlementPolicy.ts"; | ||
| 10 | |||
| 11 | const NOW = "2026-08-28T12:00:00.000Z"; | ||
| 12 | const 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 | |||
| 37 | const 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 | |||
| 50 | describe("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 @@ | ||
| 1 | import type { OrchestrationThreadShell } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export interface SettlementPullRequest { | ||
| 4 | readonly state: "open" | "closed" | "merged"; | ||
| 5 | readonly updatedAt: string | null; | ||
| 6 | } | ||
| 7 | |||
| 8 | const DAY_MS = 24 * 60 * 60 * 1_000; | ||
| 9 | export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; | ||
| 10 | |||
| 11 | function 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. */ | ||
| 28 | export 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 | |||
| 44 | function 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 | |||
| 65 | export 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. */ | ||
| 90 | export 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 @@ | ||
| 1 | import { | ||
| 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"; | ||
| 15 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | ||
| 16 | import { assert, describe, it } from "@effect/vitest"; | ||
| 17 | import * as Crypto from "effect/Crypto"; | ||
| 18 | import * as Deferred from "effect/Deferred"; | ||
| 19 | import * as Effect from "effect/Effect"; | ||
| 20 | import * as Layer from "effect/Layer"; | ||
| 21 | import * as PubSub from "effect/PubSub"; | ||
| 22 | import * as Queue from "effect/Queue"; | ||
| 23 | import * as Ref from "effect/Ref"; | ||
| 24 | import * as Stream from "effect/Stream"; | ||
| 25 | import { TestClock } from "effect/testing"; | ||
| 26 | |||
| 27 | import { GitManager } from "../git/GitManager.ts"; | ||
| 28 | import { PullRequestService } from "../pullRequest/PullRequestService.ts"; | ||
| 29 | import { ServerActivation } from "../serverActivation.ts"; | ||
| 30 | import { ServerSettingsService } from "../serverSettings.ts"; | ||
| 31 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; | ||
| 32 | import { | ||
| 33 | OrchestrationEngineService, | ||
| 34 | type OrchestrationEngineShape, | ||
| 35 | } from "./Services/OrchestrationEngine.ts"; | ||
| 36 | import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; | ||
| 37 | import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts"; | ||
| 38 | |||
| 39 | const NOW = "2026-08-28T12:00:00.000Z"; | ||
| 40 | const PROJECT_ID = ProjectId.make("settlement-project"); | ||
| 41 | const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project"); | ||
| 42 | |||
| 43 | type AutoSettleCommand = Extract<OrchestrationCommand, { readonly type: "thread.auto-settle" }>; | ||
| 44 | |||
| 45 | const testCrypto = Crypto.make({ | ||
| 46 | randomBytes: (size) => new Uint8Array(size).fill(1), | ||
| 47 | digest: (_algorithm, data) => Effect.succeed(data), | ||
| 48 | }); | ||
| 49 | |||
| 50 | function 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 | |||
| 65 | function 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 | |||
| 96 | function 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 | |||
| 108 | function 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 | |||
| 161 | interface 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 | |||
| 171 | const 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 | |||
| 271 | const 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 | |||
| 282 | describe("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 @@ | ||
| 1 | import { CommandId } from "@t3tools/contracts"; | ||
| 2 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | ||
| 3 | import * as Cause from "effect/Cause"; | ||
| 4 | import * as Context from "effect/Context"; | ||
| 5 | import * as Crypto from "effect/Crypto"; | ||
| 6 | import * as DateTime from "effect/DateTime"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import * as Schedule from "effect/Schedule"; | ||
| 10 | import type * as Scope from "effect/Scope"; | ||
| 11 | import * as Stream from "effect/Stream"; | ||
| 12 | |||
| 13 | import * as GitManager from "../git/GitManager.ts"; | ||
| 14 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; | ||
| 15 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 16 | import { forkParked } from "../serverActivation.ts"; | ||
| 17 | import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; | ||
| 18 | import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; | ||
| 19 | import { | ||
| 20 | isAutoSettlementCandidate, | ||
| 21 | shouldAutoSettleThread, | ||
| 22 | type SettlementPullRequest, | ||
| 23 | } from "./ThreadSettlementPolicy.ts"; | ||
| 24 | |||
| 25 | export 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 | |||
| 33 | export 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 | |||
| 185 | export 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"; |
| 19 | 19 | ||
| 20 | const NOW = "2026-01-01T00:00:00.000Z"; | 20 | const NOW = "2026-01-01T00:00:00.000Z"; |
| 21 | const SETTLED_AT = "2025-12-30T00:00:00.000Z"; | 21 | const SETTLED_AT = "2025-12-30T00:00:00.000Z"; |
| 22 | const SETTLE_BLOCKED_MESSAGE = | ||
| 23 | "This thread still needs attention. Resolve or interrupt it first, then try again."; | ||
| 22 | 24 | ||
| 23 | function makeReadModel( | 25 | function 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 | } |
| 80 | 82 | ||
| 81 | it.layer(NodeServices.layer)("settled thread decider", (it) => { | 83 | it.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 | }); | ||
| 242 | 268 | ||
| 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 | ); |
| 272 | 302 | ||
| ... | @@ -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]; |
| 289 | 319 | ||
| 290 | // Stale-failure detail clears the request — mirrors the projection's | 320 | // 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 | ); |
| 330 | 363 | ||
| ... | @@ -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 | }); | ||
| 356 | 393 | ||
| 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 grace | 395 | // 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"; |
| 7 | import * as DateTime from "effect/DateTime"; | 8 | import * as DateTime from "effect/DateTime"; |
| 8 | import * as Crypto from "effect/Crypto"; | 9 | import * as Crypto from "effect/Crypto"; |
| 9 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 10 | import type * as PlatformError from "effect/PlatformError"; | 11 | import type * as PlatformError from "effect/PlatformError"; |
| 11 | 12 | ||
| 12 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; | 13 | import { |
| 14 | OrchestrationCommandInvariantError, | ||
| 15 | OrchestrationThreadSettleBlockedError, | ||
| 16 | type OrchestrationCommandRejection, | ||
| 17 | } from "./Errors.ts"; | ||
| 13 | import { | 18 | import { |
| 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"; |
| 23 | import { projectEvent } from "./projector.ts"; | 28 | import { projectEvent } from "./projector.ts"; |
| 29 | import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts"; | ||
| 24 | 30 | ||
| 25 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | 31 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 26 | 32 | ||
| 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. | ||
| 30 | const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; | ||
| 31 | |||
| 32 | /** | 33 | /** |
| 33 | * Blocked-on-you work derived from the thread's retained activities: an | 34 | * Blocked-on-you work derived from the thread's retained activities: an |
| 34 | * approval or user-input request with no later resolution for the same | 35 | * 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 | } |
| 88 | 89 | ||
| 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 work | 91 | function hasQueuedTurnStartForThread( |
| 91 | * in flight even though session is still null (turn.start emits | 92 | thread: Pick<OrchestrationThread, "messages" | "latestTurn" | "session">, |
| 92 | * message-sent + turn-start-requested; the session arrives later). Detection | 93 | 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 | */ | ||
| 107 | function 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 === null | 101 | if (messageAtMs === latestUserMessageAtMs) { |
| 126 | ? Number.NEGATIVE_INFINITY | 102 | 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 | } |
| 144 | 114 | ||
| ... | @@ -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.Crypto | 160 | 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.Crypto | 194 | 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 | } |
| 452 | 422 | ||
| 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 stale | 430 | 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: a | 443 | // Pending approval / user-input requests are blocked-on-you work: a |
| 471 | // raced or stale client must not park them behind a settled override | 444 | // 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 original | 454 | // 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 in | 573 | // invisible pending work: no session, no pending flags. Snoozing in |
| 611 | // that window would hide a just-requested turn exactly the way settle | 574 | // 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({ |
| 64 | const HasEventAfterRequestSchema = Schema.Struct({ | 64 | const 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 | }); |
| 70 | 70 | ||
| ... | @@ -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 sequence | 276 | FROM orchestration_events |
| 277 | FROM orchestration_events | 277 | 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 1 | 282 | ])} |
| 283 | `, | 283 | LIMIT 1 |
| 284 | `, | ||
| 284 | }); | 285 | }); |
| 285 | 286 | ||
| 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>; |
| 55 | 55 | ||
| 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 being | 60 | * 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"; |
| 118 | import * as ExternalLauncher from "./process/externalLauncher.ts"; | 118 | import * as ExternalLauncher from "./process/externalLauncher.ts"; |
| 119 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; | 119 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; |
| 120 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; | 120 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; |
| 121 | import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; | 121 | import { |
| 122 | OrchestrationListenerCallbackError, | ||
| 123 | OrchestrationThreadSettleBlockedError, | ||
| 124 | } from "./orchestration/Errors.ts"; | ||
| 122 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | 125 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 123 | import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; | 126 | import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; |
| 124 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; | 127 | import { 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 | ); |
| 8038 | 8041 | ||
| 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 | ); |
| 8095 | 8098 | ||
| 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 | ); |
| 8107 | 8107 | ||
| 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 | }); |
| 8138 | 8118 | ||
| 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 | ); |
| 8149 | 8129 | ||
| 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 |
| 62 | import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; | 62 | import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; |
| 63 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; | 63 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; |
| 64 | import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; | 64 | import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; |
| 65 | import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; | ||
| 65 | import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; | 66 | import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; |
| 66 | import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; | 67 | import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; |
| 67 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; | 68 | import { 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 | ); |
| 292 | 294 | ||
| 295 | const 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 | |||
| 293 | const GitManagerLayerLive = GitManager.layer.pipe( | 302 | const 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 Services | 398 | // 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 | ); |
| 460 | 471 | ||
| 461 | const 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 | |||
| 469 | export const makeRoutesLayer = Layer.mergeAll( | 472 | export 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 | ); |
| 274 | 274 | ||
| 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 a | 1226 | // Archive removes the thread from the client, so this transport |
| 1227 | // live provider session must not keep running background work | 1227 | // closes its session and terminals after the command lands. |
| 1228 | // (PR monitors, dev servers, subagent fleets) after either | 1228 | // Settlement cleanup is driven by thread.settled events in the |
| 1229 | // lands. The decider rejects settling a starting/running | 1229 | // provider reactor, including settlements that have no client. |
| 1230 | // session, so for settle this only ever stops an idle one; a | 1230 | const archiveCommand = |
| 1231 | // stopped session-set does not count as activity, so the stop | 1231 | 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 read | 1233 | // 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 = parkingCommand | 1235 | 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 | }); |
| 1280 | 1267 | ||
| 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 | } |
| 1291 | 1278 | ||
| 1292 | // Terminals are user-opened panes, not thread background | 1279 | // Archive removes the thread from view, so its user-opened |
| 1293 | // work: archive removes the thread from view so they close | 1280 | // terminal panes close with it. |
| 1294 | // with it, but a settled thread stays reachable and may be | 1281 | 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"; |
| 31 | import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; | 31 | import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; |
| 32 | import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; | 32 | import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; |
| 33 | import { | 33 | import { 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"; | ||
| 39 | import { | 34 | import { |
| 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 sidebar | 4537 | // 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?.turnId | 4554 | activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId |
| 4562 | ? activePlan.steps | 4555 | ? activePlan.steps |
| 4563 | : null; | 4556 | : null; |
| 4564 | |||
| 4565 | useLayoutEffect(() => { | 4557 | useLayoutEffect(() => { |
| 4566 | if (!composerOverlayElement) return; | 4558 | if (!composerOverlayElement) return; |
| 4567 | 4559 | ||
| ... | @@ -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 same | 4630 | // 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 effect | 4639 | // 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 | >; |
| 620 | 620 | ||
| 621 | /** The timestamp a settled row sorts and labels by: settledAt when stamped | 621 | /** The timestamp a settled row sorts and labels by: settledAt when stamped, |
| 622 | (explicit settles), otherwise last activity — the same candidates | 622 | 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. */ | ||
| 626 | export function resolveSettledTimestamp(thread: SettledTimestampInput): string | null { | 623 | export 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- |
| 19 | import { CSS } from "@dnd-kit/utilities"; | 19 | import { CSS } from "@dnd-kit/utilities"; |
| 20 | import { | 20 | import { |
| 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 snoozed | 718 | // 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 for | 842 | // 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, and | 843 | // 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 | ); |
| 1940 | 1932 | ||
| 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 quantized | 1934 | // 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. The | 1935 | // 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 on | 2075 | // 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 this | 2076 | // 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 | ]); | ||
| 2176 | 2137 | ||
| 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 it | 3075 | // Un-settle pins the thread active until real activity clears the pin. |
| 3115 | // clears the override, for auto-settled rows it pins the thread | 3076 | // 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 (explicit | 3740 | // 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.capabilities | 3749 | serverConfigs.get(thread.environmentId)?.environment.capabilities |
| 3792 | .threadSettlement === true | 3750 | .threadSettlement === true |
| 3793 | } | 3751 | } |
| 3794 | autoSettleOnMerge={autoSettleOnMerge} | ||
| 3795 | snoozeSupported={ | 3752 | snoozeSupported={ |
| 3796 | serverConfigs.get(thread.environmentId)?.environment.capabilities | 3753 | serverConfigs.get(thread.environmentId)?.environment.capabilities |
| 3797 | .threadSnooze === true | 3754 | .threadSnooze === true |
apps/web/src/components/ThreadStatusIndicators.test.ts+2-34| ... | @@ -1,6 +1,4 @@ | ... | @@ -1,6 +1,4 @@ |
| 1 | import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; | 1 | import { ProjectId, type VcsStatusResult } from "@t3tools/contracts"; |
| 2 | import type { OrchestrationThreadShell } from "@t3tools/contracts"; | ||
| 3 | import { ProjectId, ProviderInstanceId, ThreadId, type VcsStatusResult } from "@t3tools/contracts"; | ||
| 4 | import { describe, expect, it } from "@effect/vitest"; | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 6 | import { AtomRegistry } from "effect/unstable/reactivity"; | 4 | import { 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 | }); |
| 497 | 495 | ||
| 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 | }); |
| 553 | 521 |
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"; |
| 13 | import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled"; | ||
| 14 | import { ChevronDownIcon } from "lucide-react"; | 13 | import { ChevronDownIcon } from "lucide-react"; |
| 15 | import { | 14 | import { |
| 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"; |
| 80 | import { ensureLocalApi, readLocalApi } from "../../localApi"; | 80 | import { ensureLocalApi, readLocalApi } from "../../localApi"; |
| 81 | import { isMacPlatform } from "../../lib/utils"; | 81 | import { isMacPlatform } from "../../lib/utils"; |
| 82 | import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server"; | 82 | import { |
| 83 | primaryServerConfigAtom, | ||
| 84 | primaryServerObservabilityAtom, | ||
| 85 | primaryServerProvidersAtom, | ||
| 86 | } from "../../state/server"; | ||
| 83 | import { useProjects } from "../../state/entities"; | 87 | import { useProjects } from "../../state/entities"; |
| 84 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; | 88 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; |
| 85 | import { formatRelativeTimeLabel } from "../../timestampFormat"; | 89 | import { 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 | /> |
| 1956 | 1962 | ||
| 1957 | <SettingsRow | 1963 | {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 | <SettingResetButton | 1969 | 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 | ) : null | 1977 | } |
| 1972 | } | 1978 | /> |
| 1973 | control={ | 1979 | ) : null |
| 1974 | <Switch | 1980 | } |
| 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 | /> | ||
| 1983 | 1991 | ||
| 1984 | <SettingsRow | 1992 | <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 | <SettingResetButton | 1998 | <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 | ) : null | 2006 | /> |
| 1999 | } | 2007 | ) : null |
| 2000 | control={ | 2008 | } |
| 2001 | <Switch | 2009 | 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 | <SettingsRow | 2024 | 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 | <AutoSettleDaysInput | 2028 | 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} |
| 2024 | 2035 | ||
| 2025 | <SettingsRow | 2036 | <SettingsRow |
apps/web/src/components/settings/settingsSearch.test.ts+22-1| ... | @@ -104,7 +104,7 @@ describe("searchSettings", () => { | ... | @@ -104,7 +104,7 @@ describe("searchSettings", () => { |
| 104 | 104 | ||
| 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 | }); |
| 110 | 110 | ||
| ... | @@ -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 | }); |
| 138 | 139 | ||
| 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 | }); |
| 153 | 157 | ||
| 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 | } |
| 34 | 35 | ||
| 35 | export interface SettingsSearchAvailability { | 36 | export 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 | } |
| 42 | 44 | ||
| 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 | } |
| 467 | 473 |
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+13-1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { useMemo } from "react"; | 1 | import { useMemo } from "react"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { AuthAccessWriteScope } from "@t3tools/contracts"; | 3 | import { AuthAccessWriteScope } from "@t3tools/contracts"; |
| 3 | 4 | ||
| 4 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; | 5 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| ... | @@ -7,6 +8,7 @@ import { desktopWslStateAtom } from "~/state/desktopWslState"; | ... | @@ -7,6 +8,7 @@ import { desktopWslStateAtom } from "~/state/desktopWslState"; |
| 7 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; | 8 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; |
| 8 | import { useEnvironmentQuery } from "~/state/query"; | 9 | import { useEnvironmentQuery } from "~/state/query"; |
| 9 | import { usePrimarySessionState } from "~/environments/primary"; | 10 | import { usePrimarySessionState } from "~/environments/primary"; |
| 11 | import { primaryServerConfigAtom } from "~/state/server"; | ||
| 10 | import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; | 12 | import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; |
| 11 | import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; | 13 | import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; |
| 12 | import { filterAvailableSettingsSearchItems } from "./settingsSearch"; | 14 | import { 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 @@ |
| 1 | import { useSyncExternalStore } from "react"; | 1 | import { useSyncExternalStore } from "react"; |
| 2 | 2 | ||
| 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. */ | ||
| 8 | 5 | ||
| 9 | function currentMinute(): string { | 6 | function 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 | } |
| 221 | 223 | ||
| 222 | function useMergedSettings<T>( | 224 | function 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"; |
| 8 | import { | 8 | import { 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"; | ||
| 14 | import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | 9 | import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; |
| 15 | import { useCallback } from "react"; | 10 | import { useCallback } from "react"; |
| 16 | 11 | ||
| ... | @@ -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"; |
| 7 | import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 7 | import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 8 | import { canSettle, canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; | 8 | import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; |
| 9 | import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | 9 | import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; |
| 10 | import * as Cause from "effect/Cause"; | 10 | import * as Cause from "effect/Cause"; |
| 11 | import * as Schema from "effect/Schema"; | 11 | import * 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 | } |
| 66 | 66 | ||
| 67 | export 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 | |||
| 79 | export class ThreadSnoozeUnsupportedError extends Schema.TaggedErrorClass<ThreadSnoozeUnsupportedError>()( | 67 | export 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 = resolved | 497 | 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 later | 88 | `settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later |
| 89 | does not define turn end. | 89 | does not define turn end. |
| 90 | 90 | ||
| 91 | Thread settlement is server-owned. Per-environment settings control PR and inactivity settlement. | ||
| 92 | [`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and | ||
| 93 | once 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 | ||
| 95 | settlement excludes live background work and requires a comparable PR timestamp for immediate PR | ||
| 96 | settlement. The command also rejects any later event for its thread after the reactor's snapshot. | ||
| 97 | Clients render the persisted settlement state and do not derive settlement from PR or inactivity | ||
| 98 | state. A committed `thread.settled` event also lets `ProviderCommandReactor` stop an idle provider | ||
| 99 | session. | ||
| 100 | |||
| 91 | ## Drainable workers | 101 | ## Drainable workers |
| 92 | 102 | ||
| 93 | Follow-up work runs asynchronously in queue-backed workers built on [`DrainableWorker`][worker]: | 103 | Follow-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, and | 105 | [`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. | ||
| 97 | 108 | ||
| 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 the | 110 | `enqueue` atomically offers and increments; processing always decrements. `drain` retries until the |
| 100 | count reaches zero, so a test can await "queue empty and current item finished" instead of sleeping. | 111 | count reaches zero, so a test can await "queue empty and current item finished" instead of sleeping. |
| 101 | Each of the three services exposes `drain` for exactly this. | 112 | Each of these four services exposes `drain` for exactly this. |
| 102 | 113 | ||
| 103 | Runtime receipts are a test-only mechanism. `RuntimeReceiptBusLive` in | 114 | Runtime receipts are a test-only mechanism. `RuntimeReceiptBusLive` in |
| 104 | [`RuntimeReceiptBus.ts`][receipts] publishes nothing; only the test layer is PubSub-backed. Do not | 115 | [`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.ts | 161 | [ingest]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts |
| 151 | [cmd]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts | 162 | [cmd]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts |
| 152 | [checkpoint]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts | 163 | [checkpoint]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts |
| 164 | [settlement]: ../../apps/server/src/orchestration/ThreadSettlementReactor.ts | ||
| 153 | [receipts]: ../../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts | 165 | [receipts]: ../../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts |
| 154 | [drivers]: ../../apps/server/src/provider/builtInDrivers.ts | 166 | [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 |
| 10 | Pinned threads still move to **Settled** when they become inactive. They also move when their pull | 10 | Pinned threads still move to **Settled** when they become inactive. They also move when their pull |
| 11 | request merges if **Auto-settle merged threads** is enabled. | 11 | request merges if **Auto-settle merged threads** is enabled. |
| 12 | 12 | ||
| 13 | Each environment owns its automatic settlement settings. The server checks them even when no web, | ||
| 14 | desktop, or mobile client is connected. By default, it settles threads after three days without | ||
| 15 | activity and when their pull request merges. An eligible idle thread also settles when its pull | ||
| 16 | request closes. An open pull request blocks inactivity settlement. Active work, pending input, and | ||
| 17 | live background work keep the thread active. T3 Code settles from a closed or merged pull request | ||
| 18 | only when its timestamp is not older than the user's latest activity. If that timestamp is not | ||
| 19 | available, the inactivity rule still applies. A manual un-settle also keeps the thread active. | ||
| 20 | Change these rules in **Settings > General** for the environment. A settings change affects future | ||
| 21 | settlement and does not reopen a settled thread. Settings saved by older clients on one device no | ||
| 22 | longer control this behavior. | ||
| 23 | |||
| 13 | When you un-settle a thread, it returns to the top of the active list so you can find it right | 24 | When you un-settle a thread, it returns to the top of the active list so you can find it right |
| 14 | away. Its timestamps do not change. Other threads keep their positions. | 25 | away. Its timestamps do not change. Other threads keep their positions. |
| 15 | 26 |
packages/client-runtime/src/state/threadSettled.test.ts deleted-587| ... | @@ -1,587 +0,0 @@ | ||
| 1 | import { | ||
| 2 | ProjectId, | ||
| 3 | ProviderInstanceId, | ||
| 4 | ThreadId, | ||
| 5 | TurnId, | ||
| 6 | type OrchestrationThreadShell, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { describe, expect, it } from "vite-plus/test"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | canSettle, | ||
| 12 | changeRequestAutoSettles, | ||
| 13 | effectiveSettled, | ||
| 14 | hasQueuedTurnStart, | ||
| 15 | threadLastActivityAt, | ||
| 16 | type ChangeRequestStateLike, | ||
| 17 | } from "./threadSettled.ts"; | ||
| 18 | |||
| 19 | const NOW = "2026-04-10T00:00:00.000Z"; | ||
| 20 | const FRESH = "2026-04-09T00:00:00.000Z"; | ||
| 21 | const STALE = "2026-04-06T23:59:59.999Z"; | ||
| 22 | |||
| 23 | describe("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 | |||
| 125 | function 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 | |||
| 176 | describe("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 | |||
| 197 | describe("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 | |||
| 434 | describe("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 | |||
| 505 | describe("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. |
| 2 | import type { OrchestrationThreadShell } from "@t3tools/contracts"; | 2 | import type { OrchestrationThreadShell } from "@t3tools/contracts"; |
| 3 | 3 | ||
| 4 | export 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 | */ | ||
| 11 | export 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. */ | ||
| 17 | export 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 | */ | ||
| 29 | function 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 | */ | ||
| 51 | export 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 | |||
| 72 | const DAY_MS = 24 * 60 * 60 * 1_000; | ||
| 73 | |||
| 74 | export 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 takes | 5 | * 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 a | 6 | * 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 | */ |
| 105 | export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; | 11 | export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; |
| 12 | const DAY_MS = 24 * 60 * 60 * 1_000; | ||
| 106 | 13 | ||
| 107 | /** | 14 | /** |
| 108 | * A user message no turn has picked up yet: the turn.start command was | 15 | * 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 | } |
| 139 | 46 | ||
| 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 | */ | ||
| 147 | export 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 raised | 48 | * The snooze lifecycle fields plus everything needed to detect a raised |
| 164 | * hand. Snooze is an overlay on the active state: a snoozed thread stays | 49 | * 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 — the | 66 | * 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 stops | 68 | * Raising a hand never clears the server-side snooze fields; it only stops |
| 184 | * the thread from CLASSIFYING as snoozed, exactly like blocked work and | 69 | * the thread from classifying as snoozed. |
| 185 | * effectiveSettled. | ||
| 186 | */ | 70 | */ |
| 187 | export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean { | 71 | export 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 | } |
| 285 | 169 | ||
| 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 | */ | ||
| 300 | export 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 | |||
| 359 | const HOUR_MS = 60 * 60 * 1_000; | 170 | const HOUR_MS = 60 * 60 * 1_000; |
| 360 | const EVENING_HOUR = 18; | 171 | const EVENING_HOUR = 18; |
| 361 | const MORNING_HOUR = 9; | 172 | const 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"; |
| 6 | import { | 6 | import { |
| 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"; |
| 16 | import type { OrchestrationThreadShell } from "@t3tools/contracts"; | ||
| 15 | 17 | ||
| 16 | const NOW = "2026-04-10T12:00:00.000Z"; | 18 | const NOW = "2026-04-10T12:00:00.000Z"; |
| 17 | const SNOOZED_AT = "2026-04-10T09:00:00.000Z"; | 19 | const 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 | } |
| 63 | 65 | ||
| 66 | type QueuedTurnShell = Pick< | ||
| 67 | OrchestrationThreadShell, | ||
| 68 | "latestUserMessageAt" | "latestTurn" | "session" | ||
| 69 | >; | ||
| 70 | |||
| 71 | function makeQueuedTurnShell(overrides: Partial<QueuedTurnShell> = {}): QueuedTurnShell { | ||
| 72 | return { latestUserMessageAt: null, latestTurn: null, session: null, ...overrides }; | ||
| 73 | } | ||
| 74 | |||
| 64 | describe("effectiveSnoozed", () => { | 75 | describe("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 | }); |
| 204 | 215 | ||
| 216 | describe("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 | |||
| 205 | describe("threadWokeAt", () => { | 265 | describe("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 and | 63 | 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. Same | 68 | /** 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 | }); |
| 774 | 774 | ||
| 775 | const ThreadAutoSettleCommand = Schema.Struct({ | ||
| 776 | type: Schema.Literal("thread.auto-settle"), | ||
| 777 | commandId: CommandId, | ||
| 778 | threadId: ThreadId, | ||
| 779 | snapshotSequence: NonNegativeInt, | ||
| 780 | }); | ||
| 781 | |||
| 775 | const ThreadUnsettleCommand = Schema.Struct({ | 782 | const 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 | }); |
| 1108 | 1115 | ||
| 1109 | const InternalOrchestrationCommand = Schema.Union([ | 1116 | const 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 | }); |
| 138 | 138 | ||
| 139 | describe("ClientSettings sidebar", () => { | 139 | describe("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 | }); |
| 146 | 143 | ||
| 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 | }); | ||
| 169 | 167 | ||
| 170 | it("allows auto-settle by inactivity to be disabled", () => { | 168 | describe("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 | }); |
| 175 | 174 | ||
| 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 | }); |
| 184 | 189 | ||
| 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 | }); |
| 190 | 195 |
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 new | 253 | // 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. New | 662 | // 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), |