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

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


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

apps/desktop/src/settings/DesktopClientSettings.test.ts-2
......@@ -40,8 +40,6 @@ const clientSettings: ClientSettings = {
4040 planModeEnabled: false,
4141 showSkillsInSlashMenu: false,
4242 providerModelPreferences: {},
43 sidebarAutoSettleAfterDays: 3,
44 sidebarAutoSettleOnMerge: true,
4543 sidebarProjectGroupingMode: "repository_path",
4644 sidebarProjectGroupingOverrides: {
4745 "environment-1:/tmp/project-a": "separate",
apps/mobile/src/features/home/HomeScreen.tsx+3-44
......@@ -53,7 +53,6 @@ import {
5353 buildThreadListV2ListItems,
5454 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
5555 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
56 type ThreadListV2ChangeRequestState,
5756 type ThreadListV2ListItem,
5857} from "../threads/threadListV2";
5958import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
......@@ -209,9 +208,6 @@ export function HomeScreen(props: HomeScreenProps) {
209208 >(() => new Map());
210209 const preferencesResult = useAtomValue(mobilePreferencesAtom);
211210 const threadListV2Enabled = useThreadListV2Enabled();
212 const autoSettleOnMerge =
213 !AsyncResult.isSuccess(preferencesResult) ||
214 preferencesResult.value.autoSettleOnMerge !== false;
215211 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
216212 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
217213 const listRef = useRef<LegendListRef | null>(null);
......@@ -487,33 +483,6 @@ export function HomeScreen(props: HomeScreenProps) {
487483 // Settled threads stay in the live shell stream (settled ≠ archived), so
488484 // the partition works directly off live shells — no snapshot merging or
489485 // 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 );
517486 const handleSettleThread = useCallback(
518487 (thread: EnvironmentThreadShell) => {
519488 void props.onSettleThread(thread);
......@@ -580,9 +549,7 @@ export function HomeScreen(props: HomeScreenProps) {
580549 toggleSettledShelf,
581550 toggleSnoozedShelf,
582551 } = useThreadListV2ShelfPreferences();
583 // now is quantized to the minute and ticks so the inactivity auto-settle
584 // boundary is actually crossed while the app stays open (mirrors web);
585 // without a clock dependency the partition memoizes a frozen "now".
552 // The queued-start and snooze helpers need a clock while the list stays open.
586553 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
587554 // Snooze wake times are second-precise; a counter bumped exactly at the
588555 // next wake boundary re-runs the partition with a fresh clock so a woken
......@@ -591,8 +558,7 @@ export function HomeScreen(props: HomeScreenProps) {
591558 useFocusEffect(
592559 useCallback(() => {
593560 if (!threadListV2Enabled) return;
594 // Refresh immediately on enable or focus: the previous value can be hours
595 // old and misclassify the inactivity auto-settle boundary until the first tick.
561 // Refresh immediately on enable or focus because the previous value can be hours old.
596562 setNowMinute(new Date().toISOString().slice(0, 16));
597563 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
598564 return () => clearInterval(id);
......@@ -679,20 +645,15 @@ export function HomeScreen(props: HomeScreenProps) {
679645 projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
680646 searchQuery: props.searchQuery,
681647 matchedThreadKeys,
682 changeRequestByKey,
683 autoSettleOnMerge,
684648 settlementEnvironmentIds,
685649 snoozeEnvironmentIds,
686650 settledLimit: settledVisibleCount,
687 now: `${nowMinute}:00.000Z`,
688 snoozeNow: new Date().toISOString(),
651 now: new Date().toISOString(),
689652 snoozedShelfExpanded,
690653 settledShelfExpanded,
691654 selectedThreadKey: null,
692655 });
693656 }, [
694 changeRequestByKey,
695 autoSettleOnMerge,
696657 nowMinute,
697658 snoozeWakeTick,
698659 snoozedShelfExpanded,
......@@ -864,7 +825,6 @@ export function HomeScreen(props: HomeScreenProps) {
864825 onPinThread={handlePinThread}
865826 onUnpinThread={handleUnpinThread}
866827 onMovePinnedThread={handleMovePinnedThread}
867 onChangeRequestState={handleChangeRequestState}
868828 projectCwd={
869829 projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null
870830 }
......@@ -874,7 +834,6 @@ export function HomeScreen(props: HomeScreenProps) {
874834 );
875835 },
876836 [
877 handleChangeRequestState,
878837 handleDeleteThread,
879838 arrangedPinnedKeys,
880839 handleMovePinnedThread,
apps/mobile/src/features/home/useThreadListActions.ts+1-11
......@@ -1,5 +1,5 @@
11import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled";
2import { canSnooze } from "@t3tools/client-runtime/state/thread-settled";
33import * as Cause from "effect/Cause";
44import * as Haptics from "expo-haptics";
55import { useCallback, useRef } from "react";
......@@ -118,16 +118,6 @@ function useThreadActionExecutor(
118118 );
119119 return false;
120120 }
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 }
131121 // Archive keeps its original, narrower guard: never interrupt a
132122 // thread mid-turn.
133123 if (
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+42-11
......@@ -34,6 +34,9 @@ import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
3434import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
3535import { runtime } from "../../lib/runtime";
3636import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
37import { serverEnvironment } from "../../state/server";
38import { useAtomCommand } from "../../state/use-atom-command";
39import type { EnvironmentId } from "@t3tools/contracts";
3740import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
3841import {
3942 type AppUpdateCheckState,
......@@ -527,26 +530,54 @@ function ConfiguredSettingsRouteScreen() {
527530}
528531
529532function GeneralSettingsSection() {
530 const preferencesResult = useAtomValue(mobilePreferencesAtom);
531 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
532 const autoSettleOnMerge =
533 !AsyncResult.isSuccess(preferencesResult) ||
534 preferencesResult.value.autoSettleOnMerge !== false;
533 const { savedConnectionsById } = useSavedRemoteConnections();
534 const connections = Object.values(savedConnectionsById).sort((left, right) =>
535 left.environmentLabel.localeCompare(right.environmentLabel),
536 );
535537
536538 return (
537539 <SettingsSection title="General">
538540 <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
539 <SettingsSwitchRow
540 icon="arrow.triangle.branch"
541 label="Auto-settle merged threads"
542 value={autoSettleOnMerge}
543 onValueChange={(value) => savePreferences({ autoSettleOnMerge: value })}
544 />
541 {connections.map((connection) => (
542 <EnvironmentAutoSettleSwitch
543 key={connection.environmentId}
544 environmentId={connection.environmentId}
545 environmentLabel={connection.environmentLabel}
546 />
547 ))}
545548 <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
546549 </SettingsSection>
547550 );
548551}
549552
553function EnvironmentAutoSettleSwitch(props: {
554 readonly environmentId: EnvironmentId;
555 readonly environmentLabel: string;
556}) {
557 const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId));
558 const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId));
559 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
560 label: "auto-settle settings update",
561 reportFailure: true,
562 });
563 if (config?.environment.capabilities.threadAutoSettlement !== true || settings === null) {
564 return null;
565 }
566 return (
567 <SettingsSwitchRow
568 icon="arrow.triangle.branch"
569 label={`Auto-settle merged threads · ${props.environmentLabel}`}
570 value={settings?.sidebarAutoSettleOnMerge ?? true}
571 onValueChange={(value) => {
572 void updateSettings({
573 environmentId: props.environmentId,
574 input: { patch: { sidebarAutoSettleOnMerge: value } },
575 });
576 }}
577 />
578 );
579}
580
550581/**
551582 * Device-local legacy toggles. Mobile has no client-settings sync, so this is
552583 * 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 {
99import { LegendList } from "@legendapp/list/react-native";
1010import type { MenuAction } from "@react-native-menu/menu";
1111import { useAtomValue } from "@effect/atom-react";
12import { AsyncResult } from "effect/unstable/reactivity";
1312import type { EnvironmentId } from "@t3tools/contracts";
1413import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
1514import { useCallback, useEffect, useMemo, useRef, useState } from "react";
......@@ -28,7 +27,6 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
2827import { NativeStackScreenOptions } from "../../native/StackHeader";
2928import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
3029import { useProjects, useThreadShells } from "../../state/entities";
31import { mobilePreferencesAtom } from "../../state/preferences";
3230import { useThreadSearch } from "../../state/queries";
3331import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
3432import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
......@@ -82,7 +80,6 @@ import {
8280 buildThreadListV2ListItems,
8381 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
8482 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
85 type ThreadListV2ChangeRequestState,
8683 type ThreadListV2ListItem,
8784} from "./threadListV2";
8885
......@@ -164,10 +161,6 @@ function ThreadNavigationSidebarPane(
164161 regenerateThreadTitle,
165162 } = useThreadListActions();
166163 const threadListV2Enabled = useThreadListV2Enabled();
167 const preferencesResult = useAtomValue(mobilePreferencesAtom);
168 const autoSettleOnMerge =
169 !AsyncResult.isSuccess(preferencesResult) ||
170 preferencesResult.value.autoSettleOnMerge !== false;
171164 const pendingTasks = usePendingNewTasks();
172165 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
173166 const environments = useMemo(
......@@ -365,33 +358,6 @@ function ThreadNavigationSidebarPane(
365358
366359 // Thread List v2 (beta) support — same model as the compact Home list
367360 // (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 );
395361 // The settled tail renders in pages; expansion resets when the filter
396362 // context changes so environment/search flips never inherit a deep page.
397363 const [settledVisibleCount, setSettledVisibleCount] = useState(
......@@ -414,9 +380,7 @@ function ThreadNavigationSidebarPane(
414380 toggleSettledShelf,
415381 toggleSnoozedShelf,
416382 } = useThreadListV2ShelfPreferences();
417 // now ticks per minute so the inactivity auto-settle boundary is actually
418 // crossed while the pane stays open; without a clock dependency the
419 // partition memoizes a frozen "now".
383 // The queued-start and snooze helpers need a clock while the pane stays open.
420384 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
421385 // Snooze wake times are second-precise; a counter bumped exactly at the
422386 // next wake boundary re-runs the partition with a fresh clock so a woken
......@@ -424,9 +388,7 @@ function ThreadNavigationSidebarPane(
424388 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
425389 useEffect(() => {
426390 if (!threadListV2Enabled) return;
427 // Refresh immediately on enable: 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.
391 // Refresh immediately because the mount-time value can be hours old.
430392 setNowMinute(new Date().toISOString().slice(0, 16));
431393 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
432394 return () => clearInterval(id);
......@@ -509,20 +471,15 @@ function ThreadNavigationSidebarPane(
509471 projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,
510472 searchQuery: props.searchQuery,
511473 matchedThreadKeys,
512 changeRequestByKey,
513 autoSettleOnMerge,
514474 settlementEnvironmentIds,
515475 snoozeEnvironmentIds,
516476 settledLimit: settledVisibleCount,
517 now: `${nowMinute}:00.000Z`,
518 snoozeNow: new Date().toISOString(),
477 now: new Date().toISOString(),
519478 snoozedShelfExpanded,
520479 settledShelfExpanded,
521480 selectedThreadKey: props.selectedThreadKey ?? null,
522481 });
523482 }, [
524 changeRequestByKey,
525 autoSettleOnMerge,
526483 nowMinute,
527484 snoozeWakeTick,
528485 snoozedShelfExpanded,
......@@ -931,7 +888,6 @@ function ThreadNavigationSidebarPane(
931888 onPinThread={pinThread}
932889 onUnpinThread={unpinThread}
933890 onMovePinnedThread={movePinnedThread}
934 onChangeRequestState={handleChangeRequestState}
935891 projectCwd={projectCwdByKey.get(scopeKey) ?? null}
936892 onSwipeableClose={handleSwipeableClose}
937893 onSwipeableWillOpen={handleSwipeableWillOpen}
......@@ -1058,7 +1014,6 @@ function ThreadNavigationSidebarPane(
10581014 arrangedPinnedKeys,
10591015 confirmDeletePendingTask,
10601016 confirmDeleteThread,
1061 handleChangeRequestState,
10621017 handleSelectThread,
10631018 handleSwipeableClose,
10641019 handleSwipeableWillOpen,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+2-24
......@@ -23,12 +23,10 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";
2323import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
2424import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
2525import {
26 resolveThreadListV2ChangeRequestState,
2726 resolveThreadListV2SnoozeMenuSelection,
2827 resolveThreadListV2SnoozeGateExpiryMs,
2928 resolveThreadListV2Status,
3029 resolveThreadListV2SwipeActions,
31 type ThreadListV2ChangeRequestState,
3230 type ThreadListV2Status,
3331} from "./threadListV2";
3432import { ThreadSearchMatchExcerpt } from "./thread-search-match";
......@@ -370,12 +368,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
370368 readonly canMovePinnedDown?: boolean;
371369 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
372370 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;
379371 readonly projectCwd?: string | null;
380372 readonly searchMatch?: EnvironmentThreadSearchMatch;
381373 readonly searchQuery?: string;
......@@ -398,24 +390,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
398390 onPinThread,
399391 onUnpinThread,
400392 onMovePinnedThread,
401 onChangeRequestState,
402393 } = props;
403394 const snoozedRow = props.snoozed === true;
404395 const pinnedRow = props.pinned === true;
405396
406397 const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);
407 const prState = pr?.state ?? null;
408 const prUpdatedAt = pr?.updatedAt ?? null;
409 const threadKey = `${thread.environmentId}:${thread.id}`;
410 useEffect(() => {
411 const changeRequest = resolveThreadListV2ChangeRequestState({
412 linkedPullRequest: thread.linkedPullRequest,
413 state: prState,
414 updatedAt: prUpdatedAt,
415 });
416 if (changeRequest === undefined) return;
417 onChangeRequestState?.(threadKey, changeRequest);
418 }, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]);
419398
420399 const theme = useUniwindTheme();
421400 const screenColor = theme["--color-screen"];
......@@ -453,9 +432,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
453432 );
454433 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
455434
456 // Swipe: the v2 primary action is the lifecycle transition. Every settled
457 // row can un-settle — explicit settles clear the override, auto-settled
458 // rows get pinned active until real activity clears the pin.
435 // Swipe: the v2 primary action is the lifecycle transition. Un-settling a
436 // settled row keeps it active until new activity clears the user override.
459437 const canUnsettle = variant === "slim";
460438 const [snoozeGateTick, bumpSnoozeGateTick] = useState(0);
461439 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";
1616import {
1717 buildThreadListV2Items,
1818 buildThreadListV2ListItems,
19 resolveThreadListV2ChangeRequestState,
2019 resolveThreadListV2Enabled,
2120 resolveThreadListV2SnoozeMenuSelection,
2221 resolveThreadListV2SnoozeGateExpiryMs,
......@@ -61,42 +60,6 @@ const linkedPullRequest = {
6160 url: "https://github.com/pingdotgg/t3code/pull/42",
6261};
6362
64describe("resolveThreadListV2ChangeRequestState", () => {
65 it("preserves the previous state while a linked pull request reloads", () => {
66 expect(
67 resolveThreadListV2ChangeRequestState({
68 linkedPullRequest,
69 state: null,
70 updatedAt: null,
71 }),
72 ).toBeUndefined();
73 });
74
75 it("clears the previous state after a pull request is unlinked", () => {
76 expect(
77 resolveThreadListV2ChangeRequestState({
78 linkedPullRequest: null,
79 state: null,
80 updatedAt: null,
81 }),
82 ).toBeNull();
83 });
84
85 it("reports a loaded linked pull request", () => {
86 expect(
87 resolveThreadListV2ChangeRequestState({
88 linkedPullRequest,
89 state: "merged",
90 updatedAt: "2026-06-02T00:00:00.000Z",
91 }),
92 ).toEqual({
93 state: "merged",
94 updatedAt: "2026-06-02T00:00:00.000Z",
95 linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]',
96 });
97 });
98});
99
10063describe("resolveThreadListV2SnoozeMenuSelection", () => {
10164 it("accepts a displayed evening preset while its wake time is still future", () => {
10265 const menuOpenedAt = new Date(2026, 4, 8, 16, 59, 30);
......@@ -319,51 +282,18 @@ describe("sortThreadsForListV2", () => {
319282});
320283
321284describe("buildThreadListV2Items", () => {
322 it("ignores the previous pull request state after a different pull request is linked", () => {
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", () => {
285 it("places a persisted settled thread in the settled shelf", () => {
349286 const thread = makeThread({
350287 id: ThreadId.make("linked-merged"),
351288 title: "Linked merged pull request",
352289 linkedPullRequest,
290 settledOverride: "settled",
291 settledAt: NOW,
353292 });
354293 const layout = buildThreadListV2Items({
355294 threads: [thread],
356295 environmentId: null,
357296 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 ]),
367297 now: NOW,
368298 });
369299
......@@ -371,23 +301,6 @@ describe("buildThreadListV2Items", () => {
371301 expect(layout.items[0]?.variant).toBe("slim");
372302 });
373303
374 it("keeps a merged thread active when auto-settle on merge is off", () => {
375 const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });
376 const layout = buildThreadListV2Items({
377 threads: [merged],
378 environmentId: null,
379 searchQuery: "",
380 changeRequestByKey: new Map([
381 [`${environmentId}:${merged.id}`, { state: "merged" as const }],
382 ]),
383 autoSettleOnMerge: false,
384 now: NOW,
385 });
386
387 expect(layout.items.map((item) => item.thread.id)).toEqual(["merged"]);
388 expect(layout.settledCount).toBe(0);
389 });
390
391304 it("hides snoozed threads and counts them — visibility parity with web", () => {
392305 const layout = buildThreadListV2Items({
393306 threads: [
......@@ -439,73 +352,21 @@ describe("buildThreadListV2Items", () => {
439352 expect(layout.settledCount).toBe(1);
440353 });
441354
442 it("moves pinned threads to the settled shelf when their pull request merges", () => {
443 const merged = makeThread({
444 id: ThreadId.make("pinned-merged"),
445 title: "Pinned merged pull request",
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",
355 it("keeps active pinned threads in the pinned block", () => {
356 const pinned = makeThread({
357 id: ThreadId.make("pinned"),
358 title: "Pinned thread",
496359 pinnedAt: "2026-06-01T12:00:00.000Z",
497360 });
498361 const layout = buildThreadListV2Items({
499 threads: [merged],
362 threads: [pinned],
500363 environmentId: null,
501364 searchQuery: "",
502 changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]),
503 autoSettleOnMerge: false,
504365 now: NOW,
505366 });
506367
507368 expect(layout.items[0]).toMatchObject({
508 thread: { id: "pinned-merged" },
369 thread: { id: "pinned" },
509370 variant: "card",
510371 pinned: true,
511372 });
......@@ -560,9 +421,7 @@ describe("buildThreadListV2Items", () => {
560421 ],
561422 environmentId: null,
562423 searchQuery: "",
563 // Minute-floored partition clock vs precise snooze clock.
564 now: "2026-06-02T00:01:00.000Z",
565 snoozeNow: "2026-06-02T00:01:07.500Z",
424 now: "2026-06-02T00:01:07.500Z",
566425 });
567426
568427 expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]);
apps/mobile/src/features/threads/threadListV2.ts+9-72
......@@ -1,22 +1,18 @@
11import {
2 effectiveSettled,
32 effectiveSnoozed,
43 hasQueuedTurnStart,
54 QUEUED_TURN_START_GRACE_MS,
65 resolveSnoozePresets,
76 snoozeWakeLabel,
87} from "@t3tools/client-runtime/state/thread-settled";
9import type {
10 ChangeRequestSettleSource,
11 SnoozePreset,
12} from "@t3tools/client-runtime/state/thread-settled";
8import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";
139import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
1410import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
1511import {
1612 activeThreadAnchorTimestampMs,
1713 sortPinnedThreadsByOrderKey,
1814} from "@t3tools/client-runtime/state/thread-sort";
19import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts";
15import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
2016
2117import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2218
......@@ -33,35 +29,6 @@ export { snoozeWakeLabel };
3329export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";
3430export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";
3531
36export interface ThreadListV2ChangeRequestState extends ChangeRequestSettleSource {
37 readonly linkedPullRequestKey?: string | null;
38}
39
40function linkedPullRequestKey(
41 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,
42): string | null {
43 if (linkedPullRequest == null) return null;
44 return JSON.stringify([
45 linkedPullRequest.projectId,
46 linkedPullRequest.repository.toLowerCase(),
47 linkedPullRequest.number,
48 ]);
49}
50
51/** Keep the previous linked PR state while its detail query reloads. */
52export function resolveThreadListV2ChangeRequestState(input: {
53 readonly linkedPullRequest: ThreadLinkedPullRequest | null | undefined;
54 readonly state: ChangeRequestSettleSource["state"] | null;
55 readonly updatedAt: string | null;
56}): ThreadListV2ChangeRequestState | null | undefined {
57 if (input.state === null) return input.linkedPullRequest == null ? null : undefined;
58 return {
59 state: input.state,
60 updatedAt: input.updatedAt,
61 linkedPullRequestKey: linkedPullRequestKey(input.linkedPullRequest),
62 };
63}
64
6532export function resolveThreadListV2SnoozeMenuSelection(input: {
6633 readonly event: string;
6734 readonly displayedPresets: ReadonlyArray<SnoozePreset>;
......@@ -347,8 +314,7 @@ export function buildThreadListV2ListItems(input: {
347314
348315/**
349316 * Partitions visible threads into the active card block (creation order) and
350 * the settled recency tail, matching the web v2 list. Mobile stores these
351 * auto-settle preferences per device.
317 * the settled recency tail, matching the web v2 list.
352318 */
353319export function buildThreadListV2Items(input: {
354320 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
......@@ -359,8 +325,6 @@ export function buildThreadListV2Items(input: {
359325 }> | null;
360326 readonly searchQuery: string;
361327 readonly matchedThreadKeys?: ReadonlySet<string>;
362 /** Per-row PR reported up by visible rows ("env:threadId" keys). */
363 readonly changeRequestByKey?: ReadonlyMap<string, ThreadListV2ChangeRequestState>;
364328 /** Environments whose server supports thread.settle/unsettle. Threads on
365329 other environments never classify as settled — the user could neither
366330 un-settle nor pin them. Absent = no gating (tests). */
......@@ -368,17 +332,10 @@ export function buildThreadListV2Items(input: {
368332 /** Environments whose server supports thread.snooze/unsnooze. Same
369333 contract as settlementEnvironmentIds. */
370334 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
371 readonly autoSettleAfterDays?: number;
372 readonly autoSettleOnMerge?: boolean;
373335 /** Max settled rows to render; the rest are counted, not built. */
374336 readonly settledLimit?: number;
375 /** Injectable for tests; defaults to now. */
376 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;
337 /** Second-precise clock used for time-based classification. */
338 readonly now: string;
382339 /** Expands the snoozed shelf into rows. Collapsed is the default. */
383340 readonly snoozedShelfExpanded?: boolean;
384341 /** Expands the settled shelf into rows. Expanded is the default. */
......@@ -387,10 +344,7 @@ export function buildThreadListV2Items(input: {
387344 a split-view detail can never lose its navigation row. */
388345 readonly selectedThreadKey?: string | null;
389346}): ThreadListV2Layout {
390 const now = input.now ?? new Date().toISOString();
391 const snoozeNow = input.snoozeNow ?? now;
392 const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
393 const autoSettleOnMerge = input.autoSettleOnMerge ?? true;
347 const now = input.now;
394348 const query = input.searchQuery.trim().toLocaleLowerCase();
395349 const projectKeys = input.projectRefs
396350 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
......@@ -402,8 +356,7 @@ export function buildThreadListV2Items(input: {
402356 const snoozed: EnvironmentThreadShell[] = [];
403357 let nextSnoozeWakeAt: string | null = null;
404358 for (const thread of input.threads) {
405 // Callers pass live (unarchived) shells; settled threads are among them
406 // and partition into the tail via effectiveSettled.
359 // Callers pass live shells. The server stamps settledOverride for the tail.
407360 if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue;
408361 if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) {
409362 continue;
......@@ -422,16 +375,8 @@ export function buildThreadListV2Items(input: {
422375 }
423376 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;
424377 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;
433378 // Snooze outranks settlement and pinning until the thread wakes.
434 if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {
379 if (supportsSnooze && effectiveSnoozed(thread, { now })) {
435380 snoozed.push(thread);
436381 if (
437382 thread.snoozedUntil != null &&
......@@ -442,15 +387,7 @@ export function buildThreadListV2Items(input: {
442387 }
443388 continue;
444389 }
445 if (
446 supportsSettlement &&
447 effectiveSettled(thread, {
448 now,
449 autoSettleAfterDays,
450 autoSettleOnMerge,
451 changeRequest,
452 })
453 ) {
390 if (supportsSettlement && thread.settledOverride === "settled") {
454391 settled.push(thread);
455392 } else if (thread.pinnedAt != null) {
456393 pinned.push(thread);
apps/mobile/src/persistence/mobile-preferences.ts-5
......@@ -31,7 +31,6 @@ export interface Preferences {
3131 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
3232 readonly projectGroupingEnabled?: boolean;
3333 readonly projectGroupingMode?: SidebarProjectGroupingMode;
34 readonly autoSettleOnMerge?: boolean;
3534 /**
3635 * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has
3736 * no client-settings sync, so the legacy grouped thread list is opted into
......@@ -101,7 +100,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {
101100 collapsedProjectGroups?: readonly string[];
102101 projectGroupingEnabled?: boolean;
103102 projectGroupingMode?: SidebarProjectGroupingMode;
104 autoSettleOnMerge?: boolean;
105103 legacyThreadListEnabled?: boolean;
106104 planModeEnabled?: boolean;
107105 threadListV2SettledShelfExpanded?: boolean;
......@@ -167,9 +165,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {
167165 ) {
168166 preferences.projectGroupingMode = parsed.projectGroupingMode;
169167 }
170 if (typeof parsed.autoSettleOnMerge === "boolean") {
171 preferences.autoSettleOnMerge = parsed.autoSettleOnMerge;
172 }
173168 if (typeof parsed.legacyThreadListEnabled === "boolean") {
174169 preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
175170 }
apps/server/integration/OrchestrationEngineHarness.integration.ts+7
......@@ -64,6 +64,7 @@ import {
6464 type OrchestrationEngineShape,
6565} from "../src/orchestration/Services/OrchestrationEngine.ts";
6666import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts";
67import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts";
6768import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";
6869import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";
6970import {
......@@ -376,6 +377,12 @@ export const makeOrchestrationIntegrationHarness = (
376377 drainThrough: () => Effect.void,
377378 }),
378379 ),
380 Layer.provideMerge(
381 Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, {
382 start: () => Effect.void,
383 drain: Effect.void,
384 }),
385 ),
379386 Layer.provideMerge(
380387 Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {
381388 publishThread: () => Effect.void,
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -209,6 +209,7 @@ export const make = Effect.gen(function* () {
209209 fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },
210210 pullRequests: true,
211211 threadSettlement: true,
212 threadAutoSettlement: true,
212213 threadSnooze: true,
213214 environmentThemes: true,
214215 threadPinning: true,
apps/server/src/git/GitManager.test.ts+420-5
......@@ -620,6 +620,7 @@ function makeManager(input?: {
620620 textGeneration?: Partial<FakeGitTextGeneration>;
621621 serverSettings?: Parameters<typeof ServerSettings.layerTest>[0];
622622 setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];
623 gitConfigReads?: string[];
623624}) {
624625 const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);
625626 const textGeneration = createTextGeneration(input?.textGeneration);
......@@ -629,11 +630,30 @@ function makeManager(input?: {
629630
630631 const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings);
631632
632 const vcsDriverLayer = GitVcsDriver.layer.pipe(
633 Layer.provideMerge(VcsProcess.layer),
634 Layer.provideMerge(NodeServices.layer),
635 Layer.provideMerge(serverConfigLayer),
636 );
633 const vcsDriverLayer = input?.gitConfigReads
634 ? Layer.effect(
635 GitVcsDriver.GitVcsDriver,
636 GitVcsDriver.make.pipe(
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 );
637657 const sourceControlRegistryLayer = Layer.effect(
638658 SourceControlProviderRegistry.SourceControlProviderRegistry,
639659 GitHubSourceControlProvider.make.pipe(
......@@ -955,6 +975,30 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
955975 }),
956976 );
957977
978 it.effect("a warm PR cache does not reread repository identity for status", () =>
979 Effect.gen(function* () {
980 const repoDir = yield* makeTempDir("t3code-git-manager-");
981 yield* initRepo(repoDir);
982 yield* runGit(repoDir, ["checkout", "-b", "feature/status-identity-cache"]);
983 const remoteDir = yield* createBareRemote();
984 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
985 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-identity-cache"]);
986
987 const gitConfigReads: string[] = [];
988 const { manager } = yield* makeManager({ gitConfigReads });
989
990 yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false });
991 gitConfigReads.length = 0;
992 yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false });
993
994 const identityReads = gitConfigReads.filter(
995 (key) =>
996 key === "branch.feature/status-identity-cache.remote" || key === "remote.origin.url",
997 );
998 expect(identityReads).toHaveLength(0);
999 }),
1000 );
1001
9581002 it.effect("status skips the provider lookup for a branch that was never pushed", () =>
9591003 Effect.gen(function* () {
9601004 const repoDir = yield* makeTempDir("t3code-git-manager-");
......@@ -974,6 +1018,377 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
9741018 }),
9751019 );
9761020
1021 it.effect("branch PR lookup returns null when the repository has no remotes", () =>
1022 Effect.gen(function* () {
1023 const repoDir = yield* makeTempDir("t3code-git-manager-");
1024 yield* initRepo(repoDir);
1025 const { manager, ghCalls } = yield* makeManager();
1026
1027 const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" });
1028
1029 expect(pullRequest).toBeNull();
1030 expect(ghCalls).toHaveLength(0);
1031 }),
1032 );
1033
1034 it.effect("branch PR lookup uses a saved tracked branch without changing checkout", () =>
1035 Effect.gen(function* () {
1036 const repoDir = yield* makeTempDir("t3code-git-manager-");
1037 yield* initRepo(repoDir);
1038 const remoteDir = yield* createBareRemote();
1039 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1040 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1041 yield* runGit(repoDir, ["checkout", "-b", "feature/saved-branch"]);
1042 yield* runGit(repoDir, ["push", "-u", "origin", "feature/saved-branch"]);
1043 yield* runGit(repoDir, ["checkout", "main"]);
1044
1045 const { manager } = yield* makeManager({
1046 ghScenario: {
1047 prListSequence: [
1048 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
1049 // @effect-diagnostics-next-line preferSchemaOverJson:off
1050 JSON.stringify([
1051 {
1052 number: 216,
1053 title: "Saved branch PR",
1054 url: "https://github.com/pingdotgg/t3code/pull/216",
1055 baseRefName: "main",
1056 headRefName: "feature/saved-branch",
1057 state: "OPEN",
1058 updatedAt: "2026-04-03T15:00:00Z",
1059 },
1060 ]),
1061 ],
1062 },
1063 });
1064
1065 const pullRequest = yield* manager.branchPullRequest({
1066 cwd: repoDir,
1067 branch: "feature/saved-branch",
1068 });
1069
1070 expect(pullRequest).toEqual({
1071 state: "open",
1072 updatedAt: "2026-04-03T15:00:00.000Z",
1073 });
1074 expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main");
1075 }),
1076 );
1077
1078 it.effect("branch PR lookup uses the default branch from a non-origin remote", () =>
1079 Effect.gen(function* () {
1080 const repoDir = yield* makeTempDir("t3code-git-manager-");
1081 yield* initRepo(repoDir);
1082 const remoteDir = yield* createBareRemote();
1083 yield* runGit(repoDir, ["remote", "add", "upstream", remoteDir]);
1084 yield* runGit(repoDir, ["push", "-u", "upstream", "main"]);
1085 yield* runGit(repoDir, ["checkout", "-b", "develop"]);
1086 yield* runGit(repoDir, ["push", "-u", "upstream", "develop"]);
1087 yield* runGit(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/develop"]);
1088 yield* runGit(repoDir, ["remote", "set-head", "upstream", "develop"]);
1089
1090 const { manager } = yield* makeManager({
1091 ghScenario: {
1092 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
1093 prListSequence: [
1094 // @effect-diagnostics-next-line preferSchemaOverJson:off
1095 JSON.stringify([
1096 {
1097 number: 221,
1098 title: "Merged main PR",
1099 url: "https://github.com/pingdotgg/codething-mvp/pull/221",
1100 baseRefName: "develop",
1101 headRefName: "main",
1102 state: "MERGED",
1103 updatedAt: "2026-04-08T15:00:00Z",
1104 },
1105 ]),
1106 ],
1107 },
1108 });
1109
1110 const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" });
1111
1112 expect(pullRequest).toEqual({
1113 state: "merged",
1114 updatedAt: "2026-04-08T15:00:00.000Z",
1115 });
1116 }),
1117 );
1118
1119 it.effect("branch PR lookup uses the saved name after the local branch is deleted", () =>
1120 Effect.gen(function* () {
1121 const repoDir = yield* makeTempDir("t3code-git-manager-");
1122 yield* initRepo(repoDir);
1123 const remoteDir = yield* createBareRemote();
1124 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1125 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1126 yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-local-branch"]);
1127 yield* runGit(repoDir, ["push", "-u", "origin", "feature/deleted-local-branch"]);
1128 yield* runGit(repoDir, ["checkout", "main"]);
1129 yield* runGit(repoDir, ["branch", "-D", "feature/deleted-local-branch"]);
1130 yield* runGit(repoDir, ["branch", "feature/deleted-local-branch/child"]);
1131 yield* runGit(repoDir, [
1132 "branch",
1133 "--set-upstream-to",
1134 "origin/main",
1135 "feature/deleted-local-branch/child",
1136 ]);
1137
1138 const { manager, ghCalls } = yield* makeManager({
1139 ghScenario: {
1140 prListSequence: [
1141 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
1142 // @effect-diagnostics-next-line preferSchemaOverJson:off
1143 JSON.stringify([
1144 {
1145 number: 217,
1146 title: "Deleted local branch PR",
1147 url: "https://github.com/pingdotgg/t3code/pull/217",
1148 baseRefName: "main",
1149 headRefName: "feature/deleted-local-branch",
1150 state: "MERGED",
1151 updatedAt: "2026-04-04T15:00:00Z",
1152 },
1153 ]),
1154 ],
1155 },
1156 });
1157
1158 const pullRequest = yield* manager.branchPullRequest({
1159 cwd: repoDir,
1160 branch: "feature/deleted-local-branch",
1161 });
1162
1163 expect(pullRequest).toEqual({
1164 state: "merged",
1165 updatedAt: "2026-04-04T15:00:00.000Z",
1166 });
1167 expect(ghCalls.some((call) => call.includes("--head feature/deleted-local-branch"))).toBe(
1168 true,
1169 );
1170 }),
1171 );
1172
1173 it.effect("branch PR lookup recovers a deleted fork branch from its remote-tracking ref", () =>
1174 Effect.gen(function* () {
1175 const repoDir = yield* makeTempDir("t3code-git-manager-");
1176 yield* initRepo(repoDir);
1177 const originDir = yield* createBareRemote();
1178 const forkDir = yield* createBareRemote();
1179 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1180 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1181 yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork");
1182 yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-fork-branch"]);
1183 yield* runGit(repoDir, ["push", "-u", "team/fork", "feature/deleted-fork-branch"]);
1184 yield* runGit(repoDir, ["checkout", "main"]);
1185 yield* runGit(repoDir, ["branch", "-D", "feature/deleted-fork-branch"]);
1186 yield* configureVisibleRemoteUrlWithLocalRewrite(
1187 repoDir,
1188 "origin",
1189 "git@github.com:pingdotgg/codething-mvp.git",
1190 originDir,
1191 );
1192 yield* configureVisibleRemoteUrlWithLocalRewrite(
1193 repoDir,
1194 "team/fork",
1195 "git@github.com:contributor/codething-mvp.git",
1196 forkDir,
1197 );
1198
1199 const { manager, ghCalls } = yield* makeManager({
1200 ghScenario: {
1201 prListByHeadSelector: {
1202 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
1203 // @effect-diagnostics-next-line preferSchemaOverJson:off
1204 "contributor:feature/deleted-fork-branch": JSON.stringify([
1205 {
1206 number: 218,
1207 title: "Deleted fork branch PR",
1208 url: "https://github.com/pingdotgg/codething-mvp/pull/218",
1209 baseRefName: "main",
1210 headRefName: "feature/deleted-fork-branch",
1211 state: "MERGED",
1212 updatedAt: "2026-04-05T15:00:00Z",
1213 isCrossRepository: true,
1214 headRepository: { nameWithOwner: "contributor/codething-mvp" },
1215 headRepositoryOwner: { login: "contributor" },
1216 },
1217 ]),
1218 },
1219 },
1220 });
1221
1222 const pullRequest = yield* manager.branchPullRequest({
1223 cwd: repoDir,
1224 branch: "feature/deleted-fork-branch",
1225 });
1226
1227 expect(pullRequest).toEqual({
1228 state: "merged",
1229 updatedAt: "2026-04-05T15:00:00.000Z",
1230 });
1231 expect(
1232 ghCalls.some((call) => call.includes("--head contributor:feature/deleted-fork-branch")),
1233 ).toBe(true);
1234 }),
1235 );
1236
1237 it.effect("branch PR lookup rejects ambiguous deleted-branch remote refs", () =>
1238 Effect.gen(function* () {
1239 const repoDir = yield* makeTempDir("t3code-git-manager-");
1240 yield* initRepo(repoDir);
1241 const originDir = yield* createBareRemote();
1242 const forkDir = yield* createBareRemote();
1243 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1244 yield* runGit(repoDir, ["remote", "add", "fork", forkDir]);
1245 yield* runGit(repoDir, ["checkout", "-b", "feature/ambiguous-remote"]);
1246 yield* runGit(repoDir, ["push", "origin", "feature/ambiguous-remote"]);
1247 yield* runGit(repoDir, ["push", "fork", "feature/ambiguous-remote"]);
1248 yield* runGit(repoDir, ["checkout", "main"]);
1249 yield* runGit(repoDir, ["branch", "-D", "feature/ambiguous-remote"]);
1250 const { manager, ghCalls } = yield* makeManager();
1251
1252 const error = yield* manager
1253 .branchPullRequest({ cwd: repoDir, branch: "feature/ambiguous-remote" })
1254 .pipe(Effect.flip);
1255
1256 expect(error).toMatchObject({
1257 _tag: "GitManagerError",
1258 detail: "Multiple remotes track feature/ambiguous-remote. Its pull request is ambiguous.",
1259 });
1260 expect(ghCalls).toHaveLength(0);
1261 }),
1262 );
1263
1264 it.effect("branch PR lookup does not reuse a cached PR after the remote is repointed", () =>
1265 Effect.gen(function* () {
1266 const repoDir = yield* makeTempDir("t3code-git-manager-");
1267 yield* initRepo(repoDir);
1268 const originalRemoteDir = yield* createBareRemote();
1269 yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]);
1270 yield* runGit(repoDir, ["checkout", "-b", "feature/repointed-lookup"]);
1271 yield* runGit(repoDir, ["push", "-u", "origin", "feature/repointed-lookup"]);
1272 yield* configureVisibleRemoteUrlWithLocalRewrite(
1273 repoDir,
1274 "origin",
1275 "git@github.com:old-owner/old-repository.git",
1276 originalRemoteDir,
1277 );
1278 const { manager, ghCalls } = yield* makeManager({
1279 ghScenario: {
1280 prListSequence: [
1281 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
1282 // @effect-diagnostics-next-line preferSchemaOverJson:off
1283 JSON.stringify([
1284 {
1285 number: 219,
1286 title: "Old repository PR",
1287 url: "https://github.com/old-owner/old-repository/pull/219",
1288 baseRefName: "main",
1289 headRefName: "feature/repointed-lookup",
1290 state: "MERGED",
1291 updatedAt: "2026-04-06T15:00:00Z",
1292 },
1293 ]),
1294 "[]",
1295 ],
1296 },
1297 });
1298
1299 const first = yield* manager.branchPullRequest({
1300 cwd: repoDir,
1301 branch: "feature/repointed-lookup",
1302 });
1303 expect(first?.state).toBe("merged");
1304
1305 const replacementRemoteDir = yield* createBareRemote();
1306 yield* configureVisibleRemoteUrlWithLocalRewrite(
1307 repoDir,
1308 "origin",
1309 "git@github.com:new-owner/new-repository.git",
1310 replacementRemoteDir,
1311 );
1312
1313 const second = yield* manager.branchPullRequest({
1314 cwd: repoDir,
1315 branch: "feature/repointed-lookup",
1316 });
1317
1318 expect(second).toBeNull();
1319 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);
1320 }),
1321 );
1322
1323 it.effect("branch PR lookup shares the status cache for the same repository identity", () =>
1324 Effect.gen(function* () {
1325 const repoDir = yield* makeTempDir("t3code-git-manager-");
1326 yield* initRepo(repoDir);
1327 const remoteDir = yield* createBareRemote();
1328 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1329 yield* runGit(repoDir, ["checkout", "-b", "feature/shared-pr-cache"]);
1330 yield* runGit(repoDir, ["push", "-u", "origin", "feature/shared-pr-cache"]);
1331 const { manager, ghCalls } = yield* makeManager({
1332 ghScenario: {
1333 prListSequence: [
1334 // Fake gh returns raw JSON stdout, matching the CLI boundary under test.
1335 // @effect-diagnostics-next-line preferSchemaOverJson:off
1336 JSON.stringify([
1337 {
1338 number: 220,
1339 title: "Shared cache PR",
1340 url: "https://github.com/pingdotgg/codething-mvp/pull/220",
1341 baseRefName: "main",
1342 headRefName: "feature/shared-pr-cache",
1343 state: "MERGED",
1344 updatedAt: "2026-04-07T15:00:00Z",
1345 },
1346 ]),
1347 ],
1348 },
1349 });
1350
1351 const status = yield* manager.status({ cwd: repoDir });
1352 const pullRequest = yield* manager.branchPullRequest({
1353 cwd: repoDir,
1354 branch: "feature/shared-pr-cache",
1355 });
1356
1357 expect(status.pr?.state).toBe("merged");
1358 expect(pullRequest?.state).toBe("merged");
1359 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);
1360 }),
1361 );
1362
1363 it.effect("branch PR lookup propagates provider failures", () =>
1364 Effect.gen(function* () {
1365 const repoDir = yield* makeTempDir("t3code-git-manager-");
1366 yield* initRepo(repoDir);
1367 const remoteDir = yield* createBareRemote();
1368 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1369 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1370 yield* runGit(repoDir, ["checkout", "-b", "feature/lookup-failure"]);
1371 yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-failure"]);
1372 yield* runGit(repoDir, ["checkout", "main"]);
1373
1374 const { manager } = yield* makeManager({
1375 ghScenario: {
1376 failWith: new GitHubCli.GitHubCliUnavailableError({
1377 command: "gh",
1378 cwd: repoDir,
1379 cause: new Error("gh is not available on PATH"),
1380 }),
1381 },
1382 });
1383
1384 const error = yield* manager
1385 .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" })
1386 .pipe(Effect.flip);
1387
1388 expect(error._tag).toBe("SourceControlProviderError");
1389 }),
1390 );
1391
9771392 it.effect("status finds a merged PR after its remote branch was deleted", () =>
9781393 Effect.gen(function* () {
9791394 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<
9090 input: VcsStatusInput,
9191 options?: GitVcsDriver.GitRemoteStatusOptions,
9292 ) => 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 >;
93101 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
94102 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
95103 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
......@@ -181,6 +189,7 @@ interface BranchHeadContext {
181189 preferredHeadSelector: string;
182190 remoteName: string | null;
183191 headRemoteUrlKey: string | null;
192 targetRemoteUrlKey: string | null;
184193 headRepositoryNameWithOwner: string | null;
185194 headRepositoryOwnerLogin: string | null;
186195 isCrossRepository: boolean;
......@@ -938,15 +947,16 @@ export const make = Effect.gen(function* () {
938947 prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1);
939948 }),
940949 );
941 // Cache keys are NUL-joined [cwd, branch, upstreamRef, defaultBranch, epoch] — none of the
942 // segments can contain a NUL byte, and refs are never empty, so "" decodes
943 // back to a null ref.
950 // Cache keys are NUL-joined. Automatic settlement validates repository URLs
951 // against the cached value before it uses a pull request decision.
944952 const prLookupCacheKey = (
945953 cwd: string,
946954 details: {
947955 branch: string;
948956 upstreamRef: string | null;
949957 defaultBranch: string | null;
958 localBranchExists?: boolean;
959 remoteName?: string | null;
950960 },
951961 ) =>
952962 [
......@@ -954,6 +964,8 @@ export const make = Effect.gen(function* () {
954964 details.branch,
955965 details.upstreamRef ?? "",
956966 details.defaultBranch ?? "",
967 details.localBranchExists === false ? "0" : "1",
968 details.remoteName ?? "",
957969 String(prLookupEpoch(cwd)),
958970 ].join("\u0000");
959971 // Consecutive failures per cache key, so a branch that keeps failing waits
......@@ -975,11 +987,20 @@ export const make = Effect.gen(function* () {
975987 };
976988 const prLookupCache = yield* Cache.makeWith(
977989 (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");
979998 const details = {
980999 branch,
9811000 upstreamRef: upstreamRef.length > 0 ? upstreamRef : null,
9821001 defaultBranch: defaultBranch.length > 0 ? defaultBranch : null,
1002 localBranchExists: branchExists !== "0",
1003 ...(remoteName.length > 0 ? { remoteName } : {}),
9831004 };
9841005 return Effect.gen(function* () {
9851006 const headContext = yield* resolveBranchHeadContext(cwd, details);
......@@ -1000,7 +1021,11 @@ export const make = Effect.gen(function* () {
10001021 }
10011022 // Only skip when the branch is untracked as well: anything carrying an
10021023 // 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 ) {
10041029 return { latest: null, headContext };
10051030 }
10061031 const latest = yield* findLatestPrForHeadContext(cwd, headContext);
......@@ -1218,11 +1243,33 @@ export const make = Effect.gen(function* () {
12181243 };
12191244 });
12201245
1246 const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")(
1247 function* (cwd: string, branch: string, remoteNameOverride?: string) {
1248 const remoteName =
1249 remoteNameOverride ?? (yield* readConfigValueNullable(cwd, `branch.${branch}.remote`));
1250 const [headRemote, targetRemote] = yield* Effect.all(
1251 [
1252 resolveRemoteRepositoryContext(cwd, remoteName),
1253 resolveRemoteRepositoryContext(cwd, "origin"),
1254 ],
1255 { concurrency: "unbounded" },
1256 );
1257 return {
1258 remoteName,
1259 headRemoteUrlKey:
1260 headRemote.remoteUrlKey ?? (remoteName === null ? targetRemote.remoteUrlKey : null),
1261 targetRemoteUrlKey: targetRemote.remoteUrlKey,
1262 };
1263 },
1264 );
1265
12211266 const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* (
12221267 cwd: string,
1223 details: { branch: string; upstreamRef: string | null },
1268 details: { branch: string; upstreamRef: string | null; remoteName?: string },
12241269 ) {
1225 const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`);
1270 const remoteName =
1271 details.remoteName ??
1272 (yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`));
12261273 const headBranchFromUpstream = details.upstreamRef
12271274 ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName })
12281275 : "";
......@@ -1286,6 +1333,7 @@ export const make = Effect.gen(function* () {
12861333 headRemoteUrlKey:
12871334 remoteRepository.remoteUrlKey ??
12881335 (remoteName === null ? originRepository.remoteUrlKey : null),
1336 targetRemoteUrlKey: originRepository.remoteUrlKey,
12891337 headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner,
12901338 headRepositoryOwnerLogin: remoteRepository.ownerLogin,
12911339 isCrossRepository,
......@@ -1869,6 +1917,140 @@ export const make = Effect.gen(function* () {
18691917 });
18701918 return mergeGitStatusParts(local, remote);
18711919 });
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 });
18722054 const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn(
18732055 "invalidateLocalStatus",
18742056 )(function* (cwd) {
......@@ -2416,6 +2598,7 @@ export const make = Effect.gen(function* () {
24162598 localStatus,
24172599 remoteStatus,
24182600 status,
2601 branchPullRequest,
24192602 invalidateLocalStatus,
24202603 invalidateRemoteStatus,
24212604 invalidateStatus,
apps/server/src/orchestration/Errors.ts+20-1
......@@ -1,3 +1,4 @@
1import { ThreadId } from "@t3tools/contracts";
12import * as SchemaIssue from "effect/SchemaIssue";
23import * as Schema from "effect/Schema";
34
......@@ -40,6 +41,24 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass<
4041 }
4142}
4243
44export class OrchestrationThreadSettleBlockedError extends Schema.TaggedErrorClass<OrchestrationThreadSettleBlockedError>()(
45 "OrchestrationThreadSettleBlockedError",
46 {
47 threadId: ThreadId,
48 },
49) {
50 override get message(): string {
51 return "This thread still needs attention. Resolve or interrupt it first, then try again.";
52 }
53}
54
55export const OrchestrationCommandRejection = Schema.Union([
56 OrchestrationCommandInvariantError,
57 OrchestrationThreadSettleBlockedError,
58]);
59export type OrchestrationCommandRejection = typeof OrchestrationCommandRejection.Type;
60export const isOrchestrationCommandRejection = Schema.is(OrchestrationCommandRejection);
61
4362export class OrchestrationCommandPreviouslyRejectedError extends Schema.TaggedErrorClass<OrchestrationCommandPreviouslyRejectedError>()(
4463 "OrchestrationCommandPreviouslyRejectedError",
4564 {
......@@ -96,7 +115,7 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass<
96115
97116export type OrchestrationDispatchError =
98117 | ProjectionRepositoryError
99 | OrchestrationCommandInvariantError
118 | OrchestrationCommandRejection
100119 | OrchestrationCommandIdConflictError
101120 | OrchestrationCommandPreviouslyRejectedError
102121 | OrchestrationProjectorDecodeError
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+211-5
......@@ -10,6 +10,7 @@ import {
1010 ProviderInstanceId,
1111} from "@t3tools/contracts";
1212import * as NodeServices from "@effect/platform-node/NodeServices";
13import { it as effectIt } from "@effect/vitest";
1314import * as Effect from "effect/Effect";
1415import * as Layer from "effect/Layer";
1516import * as ManagedRuntime from "effect/ManagedRuntime";
......@@ -17,10 +18,12 @@ import * as Metric from "effect/Metric";
1718import * as Option from "effect/Option";
1819import * as Queue from "effect/Queue";
1920import * as Stream from "effect/Stream";
21import { TestClock } from "effect/testing";
2022import { describe, expect, it } from "vite-plus/test";
2123
2224import { PersistenceSqlError } from "../../persistence/Errors.ts";
2325import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
26import * as OrchestrationCommandReceipts from "../../persistence/Services/OrchestrationCommandReceipts.ts";
2427import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
2528import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
2629import {
......@@ -46,27 +49,30 @@ const asMessageId = (value: string): MessageId => MessageId.make(value);
4649const asTurnId = (value: string): TurnId => TurnId.make(value);
4750const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value);
4851
49async function createOrchestrationSystem() {
52function makeOrchestrationLayer() {
5053 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
5154 prefix: "t3-orchestration-engine-test-",
5255 });
53 const orchestrationLayer = Layer.mergeAll(
56 return Layer.mergeAll(
5457 OrchestrationEngineLive.pipe(
5558 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
5659 Layer.provide(OrchestrationProjectionPipelineLive),
5760 ),
5861 OrchestrationProjectionSnapshotQueryLive,
5962 ).pipe(
60 Layer.provide(ThreadBackgroundLiveness.layer),
63 Layer.provideMerge(ThreadBackgroundLiveness.layer),
6164 Layer.provide(ThreadPlanProgress.layer),
6265 Layer.provide(OrchestrationEventStoreLive),
63 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
66 Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive),
6467 Layer.provide(RepositoryIdentityResolver.layer),
6568 Layer.provide(SqlitePersistenceMemory),
6669 Layer.provideMerge(ServerConfigLayer),
6770 Layer.provideMerge(NodeServices.layer),
6871 );
69 const runtime = ManagedRuntime.make(orchestrationLayer);
72}
73
74async function createOrchestrationSystem() {
75 const runtime = ManagedRuntime.make(makeOrchestrationLayer());
7076 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
7177 const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
7278 return {
......@@ -218,6 +224,7 @@ describe("OrchestrationEngine", () => {
218224 } satisfies OrchestrationProjectionPipelineShape),
219225 ),
220226 Layer.provide(Layer.succeed(OrchestrationEventStore, eventStore)),
227 Layer.provide(ThreadBackgroundLiveness.layer),
221228 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
222229 Layer.provide(SqlitePersistenceMemory),
223230 Layer.provideMerge(NodeServices.layer),
......@@ -243,6 +250,205 @@ describe("OrchestrationEngine", () => {
243250 await runtime.dispose();
244251 });
245252
253 effectIt.effect("preserves the blocked-settle error and persists its rejected receipt", () =>
254 Effect.gen(function* () {
255 const engine = yield* OrchestrationEngineService;
256 const receipts = yield* OrchestrationCommandReceipts.OrchestrationCommandReceiptRepository;
257 const projectId = ProjectId.make("project-blocked-settle");
258 const threadId = ThreadId.make("thread-blocked-settle");
259 const commandId = CommandId.make("cmd-blocked-settle");
260 const createdAt = now();
261
262 yield* engine.dispatch({
263 type: "project.create",
264 commandId: CommandId.make("cmd-blocked-settle-project-create"),
265 projectId,
266 title: "Project",
267 workspaceRoot: "/tmp/project-blocked-settle",
268 createdAt,
269 });
270 yield* engine.dispatch({
271 type: "thread.create",
272 commandId: CommandId.make("cmd-blocked-settle-thread-create"),
273 threadId,
274 projectId,
275 title: "Thread",
276 modelSelection: {
277 instanceId: ProviderInstanceId.make("codex"),
278 model: "gpt-5-codex",
279 },
280 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
281 runtimeMode: "full-access",
282 branch: null,
283 worktreePath: null,
284 createdAt,
285 });
286 yield* engine.dispatch({
287 type: "thread.session.set",
288 commandId: CommandId.make("cmd-blocked-settle-session-set"),
289 threadId,
290 createdAt,
291 session: {
292 threadId,
293 status: "running",
294 providerName: "codex",
295 runtimeMode: "full-access",
296 activeTurnId: null,
297 lastError: null,
298 updatedAt: createdAt,
299 },
300 });
301
302 const sequence = yield* engine.latestSequence;
303 const error = yield* engine
304 .dispatch({ type: "thread.settle", commandId, threadId })
305 .pipe(Effect.flip);
306 const message =
307 "This thread still needs attention. Resolve or interrupt it first, then try again.";
308 expect(error).toMatchObject({
309 _tag: "OrchestrationThreadSettleBlockedError",
310 threadId,
311 message,
312 });
313 expect(Option.getOrNull(yield* receipts.getByCommandId({ commandId }))).toMatchObject({
314 commandId,
315 aggregateKind: "thread",
316 aggregateId: threadId,
317 status: "rejected",
318 error: message,
319 resultSequence: sequence,
320 });
321 expect(yield* engine.latestSequence).toBe(sequence);
322 }).pipe(Effect.provide(makeOrchestrationLayer())),
323 );
324
325 effectIt.effect(
326 "rejects persisted changes and live background work without blocking unrelated threads",
327 () =>
328 Effect.gen(function* () {
329 yield* TestClock.setTime(Date.parse(now()));
330 const engine = yield* OrchestrationEngineService;
331 const snapshots = yield* ProjectionSnapshotQuery;
332 const backgroundLiveness = yield* ThreadBackgroundLiveness.ThreadBackgroundLivenessService;
333 const projectId = ProjectId.make("project-auto-settle-guard");
334 const guardedThreadId = ThreadId.make("thread-auto-settle-guarded");
335 const unrelatedThreadId = ThreadId.make("thread-auto-settle-unrelated");
336 const liveThreadId = ThreadId.make("thread-auto-settle-live");
337
338 yield* engine.dispatch({
339 type: "project.create",
340 commandId: CommandId.make("cmd-auto-settle-guard-project"),
341 projectId,
342 title: "Project",
343 workspaceRoot: "/tmp/project-auto-settle-guard",
344 createdAt: now(),
345 });
346 for (const threadId of [guardedThreadId, unrelatedThreadId, liveThreadId]) {
347 yield* engine.dispatch({
348 type: "thread.create",
349 commandId: CommandId.make(`cmd-create-${threadId}`),
350 threadId,
351 projectId,
352 title: "Thread",
353 modelSelection: {
354 instanceId: ProviderInstanceId.make("codex"),
355 model: "gpt-5-codex",
356 },
357 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
358 runtimeMode: "full-access",
359 branch: null,
360 worktreePath: null,
361 createdAt: now(),
362 });
363 }
364
365 const beforeUpdate = yield* snapshots.getSnapshot();
366 const snapshotSequence = beforeUpdate.snapshotSequence;
367 const originalUpdatedAt = beforeUpdate.threads.find(
368 (thread) => thread.id === guardedThreadId,
369 )?.updatedAt;
370 yield* engine.dispatch({
371 type: "thread.meta.update",
372 commandId: CommandId.make("cmd-auto-settle-guard-meta"),
373 threadId: guardedThreadId,
374 branch: "new-branch",
375 });
376 const afterUpdate = yield* snapshots.getSnapshot();
377 expect(afterUpdate.threads.find((thread) => thread.id === guardedThreadId)?.updatedAt).toBe(
378 originalUpdatedAt,
379 );
380
381 const staleError = yield* engine
382 .dispatch({
383 type: "thread.auto-settle",
384 commandId: CommandId.make("cmd-auto-settle-stale-snapshot"),
385 threadId: guardedThreadId,
386 snapshotSequence,
387 })
388 .pipe(Effect.flip);
389 expect(staleError._tag).toBe("OrchestrationCommandInvariantError");
390
391 const livenessSnapshotSequence = yield* engine.latestSequence;
392 for (const [taskType, expectedLiveness] of [
393 ["subagent", "working"],
394 ["local_bash", "monitoring"],
395 ] as const) {
396 backgroundLiveness.recordTaskLiveness({
397 threadId: liveThreadId,
398 taskId: `task-${expectedLiveness}`,
399 taskType,
400 status: undefined,
401 kind: "started",
402 });
403 expect(backgroundLiveness.getThreadBackgroundLiveness(liveThreadId)).toBe(
404 expectedLiveness,
405 );
406 expect(yield* engine.latestSequence).toBe(livenessSnapshotSequence);
407
408 const livenessError = yield* engine
409 .dispatch({
410 type: "thread.auto-settle",
411 commandId: CommandId.make(`cmd-auto-settle-${expectedLiveness}`),
412 threadId: liveThreadId,
413 snapshotSequence: livenessSnapshotSequence,
414 })
415 .pipe(Effect.flip);
416 expect(livenessError._tag).toBe("OrchestrationCommandInvariantError");
417 expect(yield* engine.latestSequence).toBe(livenessSnapshotSequence);
418 backgroundLiveness.clearThreadLiveness(liveThreadId);
419 }
420
421 yield* engine.dispatch({
422 type: "thread.auto-settle",
423 commandId: CommandId.make("cmd-auto-settle-after-liveness-cleared"),
424 threadId: liveThreadId,
425 snapshotSequence: livenessSnapshotSequence,
426 });
427
428 const freshSnapshotSequence = yield* engine.latestSequence;
429 yield* engine.dispatch({
430 type: "thread.meta.update",
431 commandId: CommandId.make("cmd-auto-settle-unrelated-meta"),
432 threadId: unrelatedThreadId,
433 title: "Unrelated update",
434 });
435 yield* engine.dispatch({
436 type: "thread.auto-settle",
437 commandId: CommandId.make("cmd-auto-settle-after-unrelated-update"),
438 threadId: guardedThreadId,
439 snapshotSequence: freshSnapshotSequence,
440 });
441
442 const settled = yield* snapshots.getSnapshot();
443 expect(
444 settled.threads.find((thread) => thread.id === guardedThreadId)?.settledOverride,
445 ).toBe("settled");
446 expect(settled.threads.find((thread) => thread.id === liveThreadId)?.settledOverride).toBe(
447 "settled",
448 );
449 }).pipe(Effect.provide(makeOrchestrationLayer())),
450 );
451
246452 it("persists deterministic read models for repeated snapshot reads", async () => {
247453 const createdAt = now();
248454 const system = await createOrchestrationSystem();
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+29-3
......@@ -33,6 +33,7 @@ import { toPersistenceSqlError } from "../../persistence/Errors.ts";
3333import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
3434import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts";
3535import {
36 isOrchestrationCommandRejection,
3637 OrchestrationCommandIdConflictError,
3738 OrchestrationCommandInvariantError,
3839 OrchestrationCommandPreviouslyRejectedError,
......@@ -43,6 +44,7 @@ import { decideOrchestrationCommand } from "../decider.ts";
4344import { createEmptyReadModel, projectEvent } from "../projector.ts";
4445import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
4546import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
47import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
4648import {
4749 OrchestrationEngineService,
4850 type OrchestrationEngineShape,
......@@ -51,7 +53,6 @@ const isOrchestrationCommandPreviouslyRejectedError = Schema.is(
5153 OrchestrationCommandPreviouslyRejectedError,
5254);
5355const isOrchestrationCommandIdConflictError = Schema.is(OrchestrationCommandIdConflictError);
54const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvariantError);
5556
5657interface CommandEnvelope {
5758 command: OrchestrationCommand;
......@@ -86,6 +87,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
8687 const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository;
8788 const projectionPipeline = yield* OrchestrationProjectionPipeline;
8889 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
90 const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService;
8991 const crypto = yield* Crypto.Crypto;
9092
9193 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
......@@ -169,13 +171,37 @@ const makeOrchestrationEngine = Effect.gen(function* () {
169171 });
170172 }
171173
174 if (
175 envelope.command.type === "thread.auto-settle" &&
176 (yield* eventStore.hasEventAfter({
177 aggregateKind: "thread",
178 aggregateId: envelope.command.threadId,
179 sequenceExclusive: envelope.command.snapshotSequence,
180 }))
181 ) {
182 return yield* new OrchestrationCommandInvariantError({
183 commandType: envelope.command.type,
184 detail: `thread ${envelope.command.threadId} changed before automatic settlement`,
185 });
186 }
187
188 if (
189 envelope.command.type === "thread.auto-settle" &&
190 threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== null
191 ) {
192 return yield* new OrchestrationCommandInvariantError({
193 commandType: envelope.command.type,
194 detail: `thread ${envelope.command.threadId} has live background work`,
195 });
196 }
197
172198 const eventBase = yield* decideOrchestrationCommand({
173199 command: envelope.command,
174200 readModel: commandReadModel,
175201 }).pipe(
176202 Effect.provideService(Crypto.Crypto, crypto),
177203 Effect.mapError((cause) =>
178 isOrchestrationCommandInvariantError(cause)
204 isOrchestrationCommandRejection(cause)
179205 ? cause
180206 : new OrchestrationCommandInvariantError({
181207 commandType: envelope.command.type,
......@@ -307,7 +333,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
307333 ),
308334 );
309335
310 if (isOrchestrationCommandInvariantError(error)) {
336 if (isOrchestrationCommandRejection(error)) {
311337 yield* commandReceiptRepository
312338 .upsert({
313339 commandId: envelope.command.commandId,
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+12-1
......@@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts";
99import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
1010import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
1111import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
12import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
1213import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
1314import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
1415import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
......@@ -23,7 +24,7 @@ describe("OrchestrationReactor", () => {
2324 runtime = null;
2425 });
2526
26 it("starts provider ingestion, provider command, checkpoint, and thread deletion reactors", async () => {
27 it("starts every orchestration reactor", async () => {
2728 const started: string[] = [];
2829
2930 runtime = ManagedRuntime.make(
......@@ -64,6 +65,15 @@ describe("OrchestrationReactor", () => {
6465 drainThrough: () => Effect.void,
6566 }),
6667 ),
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 ),
6777 Layer.provideMerge(
6878 Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {
6979 publishThread: () => Effect.void,
......@@ -85,6 +95,7 @@ describe("OrchestrationReactor", () => {
8595 "provider-command-reactor",
8696 "checkpoint-reactor",
8797 "thread-deletion-reactor",
98 "thread-settlement-reactor",
8899 "agent-awareness-relay",
89100 ]);
90101
apps/server/src/orchestration/Layers/OrchestrationReactor.ts+3
......@@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts";
99import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
1010import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
1111import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
12import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
1213import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
1314
1415export const makeOrchestrationReactor = Effect.gen(function* () {
......@@ -16,6 +17,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
1617 const providerCommandReactor = yield* ProviderCommandReactor;
1718 const checkpointReactor = yield* CheckpointReactor;
1819 const threadDeletionReactor = yield* ThreadDeletionReactor;
20 const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
1921 const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
2022
2123 const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () {
......@@ -23,6 +25,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
2325 yield* providerCommandReactor.start();
2426 yield* checkpointReactor.start();
2527 yield* threadDeletionReactor.start();
28 yield* threadSettlementReactor.start();
2629 yield* agentAwarenessRelay.start();
2730 });
2831
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+45
......@@ -3229,4 +3229,49 @@ describe("ProviderCommandReactor", () => {
32293229 expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work"));
32303230 expect(thread?.session?.activeTurnId).toBeNull();
32313231 });
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 );
32323277});
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+22-2
......@@ -61,7 +61,8 @@ type ProviderIntentEvent = Extract<
6161 | "thread.turn-interrupt-requested"
6262 | "thread.approval-response-requested"
6363 | "thread.user-input-response-requested"
64 | "thread.session-stop-requested";
64 | "thread.session-stop-requested"
65 | "thread.settled";
6566 }
6667>;
6768
......@@ -1490,6 +1491,24 @@ const make = Effect.gen(function* () {
14901491 case "thread.session-stop-requested":
14911492 yield* processSessionStopRequested(event);
14921493 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 }
14931512 }
14941513 });
14951514
......@@ -1528,7 +1547,8 @@ const make = Effect.gen(function* () {
15281547 event.type === "thread.turn-interrupt-requested" ||
15291548 event.type === "thread.approval-response-requested" ||
15301549 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"
15321552 ) {
15331553 return yield* worker.enqueue(event);
15341554 }
apps/server/src/orchestration/ThreadSettlementPolicy.test.ts created+159
......@@ -0,0 +1,159 @@
1import { describe, expect, it } from "vite-plus/test";
2import {
3 ProviderInstanceId,
4 ThreadId,
5 ProjectId,
6 TurnId,
7 type OrchestrationThreadShell,
8} from "@t3tools/contracts";
9import { shouldAutoSettleThread } from "./ThreadSettlementPolicy.ts";
10
11const NOW = "2026-08-28T12:00:00.000Z";
12const makeThread = (
13 overrides: Partial<OrchestrationThreadShell> = {},
14): OrchestrationThreadShell => ({
15 id: ThreadId.make("thread-1"),
16 projectId: ProjectId.make("project-1"),
17 title: "Thread",
18 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
19 runtimeMode: "full-access",
20 interactionMode: "default",
21 branch: "feature",
22 worktreePath: "/repo",
23 latestTurn: null,
24 createdAt: "2026-08-01T00:00:00.000Z",
25 updatedAt: "2026-08-20T00:00:00.000Z",
26 archivedAt: null,
27 settledOverride: null,
28 settledAt: null,
29 session: null,
30 latestUserMessageAt: "2026-08-20T00:00:00.000Z",
31 hasPendingApprovals: false,
32 hasPendingUserInput: false,
33 hasActionableProposedPlan: false,
34 ...overrides,
35});
36
37const decide = (
38 thread: OrchestrationThreadShell,
39 pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null,
40 settings: { days?: number | null; merge?: boolean } = {},
41) =>
42 shouldAutoSettleThread({
43 thread,
44 pullRequest,
45 now: NOW,
46 autoSettleAfterDays: settings.days === undefined ? 3 : settings.days,
47 autoSettleOnMerge: settings.merge ?? true,
48 });
49
50describe("shouldAutoSettleThread", () => {
51 it("settles inactive threads and leaves never-used threads active", () => {
52 expect(decide(makeThread())).toBe(true);
53 expect(decide(makeThread({ latestUserMessageAt: null }))).toBe(false);
54 expect(decide(makeThread(), null, { days: null })).toBe(false);
55 });
56
57 it("keeps a thread active at the exact inactivity boundary", () => {
58 expect(decide(makeThread({ latestUserMessageAt: "2026-08-25T12:00:00.000Z" }))).toBe(false);
59 });
60
61 it("keeps open pull requests active", () => {
62 expect(decide(makeThread(), { state: "open", updatedAt: NOW })).toBe(false);
63 });
64
65 it("settles closed requests and honors the merge setting", () => {
66 expect(decide(makeThread(), { state: "closed", updatedAt: NOW }, { merge: false })).toBe(true);
67 expect(decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false })).toBe(true);
68 expect(
69 decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false, days: null }),
70 ).toBe(false);
71 });
72
73 it("does not settle again after user activity newer than the PR", () => {
74 expect(
75 decide(
76 makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }),
77 { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" },
78 { days: null },
79 ),
80 ).toBe(false);
81 });
82
83 it("does not inherit a terminal pull request older than the thread", () => {
84 expect(
85 decide(
86 makeThread({ createdAt: "2026-08-20T00:00:00.000Z", latestUserMessageAt: null }),
87 { state: "closed", updatedAt: "2026-08-19T00:00:00.000Z" },
88 { days: null },
89 ),
90 ).toBe(false);
91 });
92
93 it("requires a comparable PR timestamp for immediate settlement", () => {
94 const recentThread = makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" });
95 expect(decide(recentThread, { state: "closed", updatedAt: null })).toBe(false);
96 expect(decide(recentThread, { state: "merged", updatedAt: "unknown" })).toBe(false);
97 expect(decide(makeThread(), { state: "closed", updatedAt: null })).toBe(true);
98 });
99
100 it("uses user request time instead of completion time as the PR anchor", () => {
101 const thread = makeThread({
102 latestTurn: {
103 turnId: TurnId.make("turn-1"),
104 state: "completed",
105 requestedAt: "2026-08-25T00:00:00.000Z",
106 startedAt: "2026-08-25T00:01:00.000Z",
107 completedAt: "2026-08-27T00:00:00.000Z",
108 assistantMessageId: null,
109 },
110 });
111 expect(decide(thread, { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" })).toBe(true);
112 });
113
114 it("blocks pins, snooze, pending work, live sessions, and queued starts", () => {
115 expect(decide(makeThread({ settledOverride: "active" }))).toBe(false);
116 expect(decide(makeThread({ snoozedUntil: "2026-08-29T00:00:00.000Z" }))).toBe(false);
117 expect(decide(makeThread({ hasPendingApprovals: true }))).toBe(false);
118 expect(decide(makeThread({ hasPendingUserInput: true }))).toBe(false);
119 expect(decide(makeThread({ backgroundLiveness: "working" }))).toBe(false);
120 expect(decide(makeThread({ backgroundLiveness: "monitoring" }))).toBe(false);
121 expect(
122 decide(
123 makeThread({
124 session: {
125 threadId: ThreadId.make("thread-1"),
126 status: "running",
127 providerName: "codex",
128 runtimeMode: "full-access",
129 activeTurnId: TurnId.make("turn-1"),
130 lastError: null,
131 updatedAt: NOW,
132 },
133 }),
134 ),
135 ).toBe(false);
136 expect(
137 decide(makeThread({ latestUserMessageAt: "2026-08-28T11:59:00.000Z", latestTurn: null })),
138 ).toBe(false);
139 });
140
141 it("allows a fresh completion to wake snooze before settlement", () => {
142 expect(
143 decide(
144 makeThread({
145 snoozedAt: "2026-08-19T00:00:00.000Z",
146 snoozedUntil: "2026-08-29T00:00:00.000Z",
147 latestTurn: {
148 turnId: TurnId.make("turn-woke"),
149 state: "completed",
150 requestedAt: "2026-08-18T00:00:00.000Z",
151 startedAt: "2026-08-18T00:01:00.000Z",
152 completedAt: "2026-08-20T00:00:00.000Z",
153 assistantMessageId: null,
154 },
155 }),
156 ),
157 ).toBe(true);
158 });
159});
apps/server/src/orchestration/ThreadSettlementPolicy.ts created+108
......@@ -0,0 +1,108 @@
1import type { OrchestrationThreadShell } from "@t3tools/contracts";
2
3export interface SettlementPullRequest {
4 readonly state: "open" | "closed" | "merged";
5 readonly updatedAt: string | null;
6}
7
8const DAY_MS = 24 * 60 * 60 * 1_000;
9export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;
10
11function latestTimestamp(values: ReadonlyArray<string | null | undefined>): string | null {
12 let latest: string | null = null;
13 let latestMs = Number.NEGATIVE_INFINITY;
14 for (const value of values) {
15 if (value == null) continue;
16 const valueMs = Date.parse(value);
17 if (valueMs > latestMs) {
18 latest = value;
19 latestMs = valueMs;
20 }
21 }
22 return latest;
23}
24
25/** A recent user message stays queued until a turn adopts its timestamp.
26 * Absolute age bounds client clock skew in both directions and stops stale
27 * pre-adoption data from blocking the thread forever. */
28export function threadHasQueuedTurnStart(
29 thread: Pick<OrchestrationThreadShell, "latestUserMessageAt" | "latestTurn" | "session">,
30 now: string,
31): boolean {
32 if (thread.latestUserMessageAt === null || thread.session?.status === "error") return false;
33 const messageAt = Date.parse(thread.latestUserMessageAt);
34 const age = Date.parse(now) - messageAt;
35 if (Number.isNaN(age) || Math.abs(age) > QUEUED_TURN_START_GRACE_MS) return false;
36 if (thread.latestTurn === null) return true;
37 return [
38 thread.latestTurn.requestedAt,
39 thread.latestTurn.startedAt,
40 thread.latestTurn.completedAt,
41 ].every((value) => value == null || Date.parse(value) < messageAt);
42}
43
44function pullRequestSettles(
45 thread: Pick<OrchestrationThreadShell, "createdAt" | "latestUserMessageAt" | "latestTurn">,
46 pullRequest: SettlementPullRequest,
47 autoSettleOnMerge: boolean,
48): boolean {
49 if (pullRequest.state !== "closed" && (pullRequest.state !== "merged" || !autoSettleOnMerge)) {
50 return false;
51 }
52 if (pullRequest.updatedAt === null) return false;
53 const userAnchor = latestTimestamp([
54 thread.createdAt,
55 thread.latestUserMessageAt,
56 thread.latestTurn?.requestedAt,
57 ]);
58 if (userAnchor === null) return false;
59 const pullRequestAt = Date.parse(pullRequest.updatedAt);
60 const userAnchorAt = Date.parse(userAnchor);
61 if (Number.isNaN(pullRequestAt) || Number.isNaN(userAnchorAt)) return false;
62 return pullRequestAt >= userAnchorAt;
63}
64
65export function shouldAutoSettleThread(input: {
66 readonly thread: OrchestrationThreadShell;
67 readonly pullRequest: SettlementPullRequest | null;
68 readonly now: string;
69 readonly autoSettleAfterDays: number | null;
70 readonly autoSettleOnMerge: boolean;
71}): boolean {
72 const { thread, pullRequest } = input;
73 if (!isAutoSettlementCandidate(thread, input.now)) return false;
74 if (pullRequest !== null) {
75 if (pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) return true;
76 if (pullRequest.state === "open") return false;
77 }
78 if (input.autoSettleAfterDays === null) return false;
79 const activityAt = latestTimestamp([
80 thread.latestUserMessageAt,
81 thread.latestTurn?.requestedAt,
82 thread.latestTurn?.startedAt,
83 thread.latestTurn?.completedAt,
84 ]);
85 if (activityAt === null) return false;
86 return Date.parse(activityAt) < Date.parse(input.now) - input.autoSettleAfterDays * DAY_MS;
87}
88
89/** Cheap checks that run before any source control lookup. */
90export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now: string): boolean {
91 if (thread.archivedAt !== null || thread.settledOverride !== null) return false;
92 if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false;
93 if (thread.session?.status === "starting" || thread.session?.status === "running") return false;
94 if (thread.backgroundLiveness != null) return false;
95 if (threadHasQueuedTurnStart(thread, now)) return false;
96 if (thread.snoozedUntil == null || Date.parse(thread.snoozedUntil) <= Date.parse(now))
97 return true;
98 const wokeOnError =
99 thread.session?.status === "error" &&
100 (thread.snoozedAt == null ||
101 Date.parse(thread.session.updatedAt) > Date.parse(thread.snoozedAt));
102 const wokeOnCompletion =
103 thread.snoozedAt != null &&
104 thread.latestTurn?.state === "completed" &&
105 thread.latestTurn.completedAt != null &&
106 Date.parse(thread.latestTurn.completedAt) > Date.parse(thread.snoozedAt);
107 return wokeOnError || wokeOnCompletion;
108}
apps/server/src/orchestration/ThreadSettlementReactor.test.ts created+640
......@@ -0,0 +1,640 @@
1import {
2 DEFAULT_SERVER_SETTINGS,
3 ProjectId,
4 ProviderInstanceId,
5 PullRequestOperationError,
6 ThreadId,
7 type OrchestrationCommand,
8 type OrchestrationProjectShell,
9 type OrchestrationShellSnapshot,
10 type OrchestrationThreadShell,
11 type PullRequestDetail,
12 type ServerSettings,
13 type ServerSettingsPatch,
14} from "@t3tools/contracts";
15import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
16import { assert, describe, it } from "@effect/vitest";
17import * as Crypto from "effect/Crypto";
18import * as Deferred from "effect/Deferred";
19import * as Effect from "effect/Effect";
20import * as Layer from "effect/Layer";
21import * as PubSub from "effect/PubSub";
22import * as Queue from "effect/Queue";
23import * as Ref from "effect/Ref";
24import * as Stream from "effect/Stream";
25import { TestClock } from "effect/testing";
26
27import { GitManager } from "../git/GitManager.ts";
28import { PullRequestService } from "../pullRequest/PullRequestService.ts";
29import { ServerActivation } from "../serverActivation.ts";
30import { ServerSettingsService } from "../serverSettings.ts";
31import { OrchestrationCommandInvariantError } from "./Errors.ts";
32import {
33 OrchestrationEngineService,
34 type OrchestrationEngineShape,
35} from "./Services/OrchestrationEngine.ts";
36import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
37import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts";
38
39const NOW = "2026-08-28T12:00:00.000Z";
40const PROJECT_ID = ProjectId.make("settlement-project");
41const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project");
42
43type AutoSettleCommand = Extract<OrchestrationCommand, { readonly type: "thread.auto-settle" }>;
44
45const testCrypto = Crypto.make({
46 randomBytes: (size) => new Uint8Array(size).fill(1),
47 digest: (_algorithm, data) => Effect.succeed(data),
48});
49
50function makeProject(
51 id: ProjectId = PROJECT_ID,
52 workspaceRoot = "/workspace/project",
53): OrchestrationProjectShell {
54 return {
55 id,
56 title: `Project ${id}`,
57 workspaceRoot,
58 defaultModelSelection: null,
59 scripts: [],
60 createdAt: "2026-08-01T00:00:00.000Z",
61 updatedAt: NOW,
62 };
63}
64
65function makeThread(
66 id: string,
67 overrides: Partial<OrchestrationThreadShell> = {},
68): OrchestrationThreadShell {
69 return {
70 id: ThreadId.make(id),
71 projectId: PROJECT_ID,
72 title: id,
73 modelSelection: {
74 instanceId: ProviderInstanceId.make("codex"),
75 model: "gpt-5",
76 },
77 runtimeMode: "full-access",
78 interactionMode: "default",
79 branch: null,
80 worktreePath: null,
81 latestTurn: null,
82 createdAt: "2026-08-01T00:00:00.000Z",
83 updatedAt: "2026-08-20T00:00:00.000Z",
84 archivedAt: null,
85 settledOverride: null,
86 settledAt: null,
87 session: null,
88 latestUserMessageAt: "2026-08-20T00:00:00.000Z",
89 hasPendingApprovals: false,
90 hasPendingUserInput: false,
91 hasActionableProposedPlan: false,
92 ...overrides,
93 };
94}
95
96function makeSnapshot(
97 threads: ReadonlyArray<OrchestrationThreadShell>,
98 projects: ReadonlyArray<OrchestrationProjectShell> = [makeProject()],
99): OrchestrationShellSnapshot {
100 return {
101 snapshotSequence: 1,
102 projects,
103 threads,
104 updatedAt: NOW,
105 };
106}
107
108function makePullRequestDetail(input: {
109 readonly projectId: ProjectId;
110 readonly repository: string;
111 readonly number: number;
112 readonly state: "open" | "closed" | "merged";
113 readonly updatedAt?: string;
114}): PullRequestDetail {
115 return {
116 provider: "github",
117 capabilities: {
118 diff: true,
119 comment: true,
120 actions: [],
121 mergeMethods: [],
122 search: true,
123 review: { inlineComment: true, reply: true, resolve: true, verdicts: [] },
124 reviewers: { request: true, listCandidates: true },
125 },
126 viewerPermissions: {
127 actions: [],
128 comment: true,
129 resolve: true,
130 verdicts: [],
131 requestReviewers: true,
132 },
133 projectId: input.projectId,
134 projectTitle: "Linked project",
135 workspaceRoot: "/workspace/linked",
136 repository: input.repository,
137 number: input.number,
138 title: "Pull request",
139 body: "",
140 url: `https://example.test/${input.repository}/pull/${input.number}`,
141 author: null,
142 state: input.state,
143 isDraft: false,
144 mergeability: "mergeable",
145 additions: 0,
146 deletions: 0,
147 changedFiles: 0,
148 headBranch: "feature",
149 baseBranch: "main",
150 createdAt: "2026-08-01T00:00:00.000Z",
151 updatedAt: input.updatedAt ?? NOW,
152 mergedAt: input.state === "merged" ? (input.updatedAt ?? NOW) : null,
153 closedAt: input.state === "closed" ? (input.updatedAt ?? NOW) : null,
154 reviewers: [],
155 labels: [],
156 checks: [],
157 mergeCapabilities: { merge: true, squash: true, rebase: true },
158 };
159}
160
161interface HarnessOptions {
162 readonly snapshot: OrchestrationShellSnapshot;
163 readonly settings?: ServerSettings;
164 readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"];
165 readonly pullRequestDetail?: PullRequestService["Service"]["detail"];
166 readonly onDispatch?: (
167 command: AutoSettleCommand,
168 ) => Effect.Effect<void, OrchestrationCommandInvariantError>;
169}
170
171const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: HarnessOptions) {
172 const activation = yield* Deferred.make<void>();
173 const snapshots = yield* Ref.make(options.snapshot);
174 const snapshotReadCount = yield* Ref.make(0);
175 const snapshotReads = yield* Queue.unbounded<number>();
176 const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS);
177 const settingsChanges = yield* PubSub.unbounded<ServerSettings>();
178 const commands = yield* Ref.make<ReadonlyArray<AutoSettleCommand>>([]);
179 const branchCalls = yield* Ref.make<
180 ReadonlyArray<{ readonly cwd: string; readonly branch: string }>
181 >([]);
182 const detailCalls = yield* Ref.make<
183 ReadonlyArray<{
184 readonly projectId: ProjectId;
185 readonly repository: string;
186 readonly number: number;
187 }>
188 >([]);
189
190 const updateSettings = (patch: ServerSettingsPatch) =>
191 Effect.gen(function* () {
192 const next = applyServerSettingsPatch(yield* Ref.get(settings), patch);
193 yield* Ref.set(settings, next);
194 yield* PubSub.publish(settingsChanges, next);
195 return next;
196 });
197
198 const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) =>
199 Ref.update(branchCalls, (calls) => [...calls, input]).pipe(
200 Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)),
201 );
202
203 const pullRequestDetail: PullRequestService["Service"]["detail"] = (input) =>
204 Ref.update(detailCalls, (calls) => [...calls, input]).pipe(
205 Effect.andThen(
206 options.pullRequestDetail?.(input) ??
207 Effect.succeed(
208 makePullRequestDetail({
209 ...input,
210 state: "open",
211 }),
212 ),
213 ),
214 );
215
216 const dispatch: OrchestrationEngineShape["dispatch"] = (command) => {
217 if (command.type !== "thread.auto-settle") {
218 return Effect.die(new Error(`Unexpected command: ${command.type}`));
219 }
220 return Ref.update(commands, (recorded) => [...recorded, command]).pipe(
221 Effect.andThen(options.onDispatch?.(command) ?? Effect.void),
222 Effect.as({ sequence: 1 }),
223 );
224 };
225
226 const serverSettings = ServerSettingsService.of({
227 start: Effect.void,
228 ready: Effect.void,
229 getSettings: Ref.get(settings),
230 updateSettings,
231 streamChanges: Stream.fromPubSub(settingsChanges),
232 subscribeChanges: PubSub.subscribe(settingsChanges).pipe(
233 Effect.map((subscription) => Stream.fromSubscription(subscription)),
234 ),
235 });
236
237 const dependencies = Layer.mergeAll(
238 Layer.mock(ProjectionSnapshotQuery)({
239 getShellSnapshot: () =>
240 Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe(
241 Effect.tap((count) => Queue.offer(snapshotReads, count)),
242 Effect.andThen(Ref.get(snapshots)),
243 ),
244 }),
245 Layer.mock(GitManager)({ branchPullRequest }),
246 Layer.mock(PullRequestService)({ detail: pullRequestDetail }),
247 Layer.mock(OrchestrationEngineService)({
248 readEvents: () => Stream.empty,
249 dispatch,
250 streamDomainEvents: Stream.empty,
251 latestSequence: Effect.succeed(0),
252 }),
253 Layer.succeed(ServerSettingsService, serverSettings),
254 Layer.succeed(ServerActivation, Deferred.await(activation)),
255 Layer.succeed(Crypto.Crypto, testCrypto),
256 );
257
258 return {
259 activation,
260 snapshots,
261 snapshotReadCount,
262 snapshotReads,
263 commands,
264 branchCalls,
265 detailCalls,
266 updateSettings,
267 layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)),
268 };
269});
270
271const startHarness = Effect.fn("startThreadSettlementHarness")(function* (
272 reactor: ThreadSettlementReactor.ThreadSettlementReactor["Service"],
273 activation: Deferred.Deferred<void>,
274 snapshotReads: Queue.Queue<number>,
275) {
276 yield* reactor.start();
277 yield* Deferred.succeed(activation, undefined);
278 yield* Queue.take(snapshotReads);
279 yield* reactor.drain;
280});
281
282describe("ThreadSettlementReactor", () => {
283 it.effect("starts without clients and skips protected threads before pull request lookup", () =>
284 Effect.scoped(
285 Effect.gen(function* () {
286 yield* TestClock.setTime(Date.parse(NOW));
287 const linkedPullRequest = {
288 projectId: LINKED_PROJECT_ID,
289 repository: "owner/repository",
290 number: 42,
291 url: "https://example.test/owner/repository/pull/42",
292 } as const;
293 const skipped = [
294 makeThread("pending-approval", {
295 branch: "skip-approval",
296 hasPendingApprovals: true,
297 }),
298 makeThread("snoozed", {
299 branch: "skip-snoozed",
300 snoozedUntil: "2026-08-29T00:00:00.000Z",
301 }),
302 ];
303 const fixture = yield* makeHarness({
304 snapshot: makeSnapshot(
305 [
306 makeThread("inactive", { branch: "inactive-feature" }),
307 makeThread("closed-pr", { linkedPullRequest }),
308 ...skipped,
309 ],
310 [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
311 ),
312 branchPullRequest: () => Effect.succeed(null),
313 pullRequestDetail: (input) =>
314 Effect.succeed(makePullRequestDetail({ ...input, state: "closed" })),
315 });
316
317 yield* Effect.gen(function* () {
318 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
319 yield* reactor.start();
320 assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 0);
321
322 yield* Deferred.succeed(fixture.activation, undefined);
323 yield* Queue.take(fixture.snapshotReads);
324 yield* reactor.drain;
325
326 const commands = yield* Ref.get(fixture.commands);
327 assert.deepStrictEqual(
328 commands
329 .map(({ threadId, snapshotSequence }) => ({ threadId, snapshotSequence }))
330 .sort((left, right) => left.threadId.localeCompare(right.threadId)),
331 [
332 {
333 threadId: ThreadId.make("closed-pr"),
334 snapshotSequence: 1,
335 },
336 {
337 threadId: ThreadId.make("inactive"),
338 snapshotSequence: 1,
339 },
340 ],
341 );
342 assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [
343 { cwd: "/workspace/project", branch: "inactive-feature" },
344 ]);
345 assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [
346 { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 },
347 ]);
348 }).pipe(Effect.provide(fixture.layer));
349 }),
350 ),
351 );
352
353 it.effect("reevaluates inactivity and pull request state once per minute", () =>
354 Effect.scoped(
355 Effect.gen(function* () {
356 yield* TestClock.setTime(Date.parse(NOW));
357 const pullRequest = yield* Ref.make<"open" | "merged">("open");
358 const fixture = yield* makeHarness({
359 snapshot: makeSnapshot([
360 makeThread("at-boundary", {
361 latestUserMessageAt: "2026-08-25T12:00:00.000Z",
362 }),
363 makeThread("open-pr", {
364 branch: "saved-feature",
365 latestUserMessageAt: "2026-08-27T00:00:00.000Z",
366 }),
367 ]),
368 branchPullRequest: () =>
369 Ref.get(pullRequest).pipe(Effect.map((state) => ({ state, updatedAt: NOW }))),
370 });
371
372 yield* Effect.gen(function* () {
373 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
374 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
375 assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
376
377 yield* Ref.set(pullRequest, "merged");
378 yield* TestClock.adjust("1 minute");
379 yield* Queue.take(fixture.snapshotReads);
380 yield* reactor.drain;
381
382 assert.deepStrictEqual(
383 (yield* Ref.get(fixture.commands))
384 .map((command) => command.threadId)
385 .sort((left, right) => left.localeCompare(right)),
386 [ThreadId.make("at-boundary"), ThreadId.make("open-pr")],
387 );
388 assert.strictEqual((yield* Ref.get(fixture.branchCalls)).length, 2);
389 }).pipe(Effect.provide(fixture.layer));
390 }),
391 ),
392 );
393
394 it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () =>
395 Effect.scoped(
396 Effect.gen(function* () {
397 yield* TestClock.setTime(Date.parse(NOW));
398 const state = yield* Ref.make<"merged" | "closed">("merged");
399 const firstLookupStarted = yield* Deferred.make<void>();
400 const releaseFirstLookup = yield* Deferred.make<void>();
401 const laterLookupStarted = yield* Deferred.make<void>();
402 const releaseLaterLookup = yield* Deferred.make<void>();
403 const lookupCount = yield* Ref.make(0);
404 const fixture = yield* makeHarness({
405 snapshot: makeSnapshot([makeThread("settings-thread", { branch: "saved-feature" })]),
406 settings: {
407 ...DEFAULT_SERVER_SETTINGS,
408 sidebarAutoSettleAfterDays: null,
409 sidebarAutoSettleOnMerge: true,
410 },
411 branchPullRequest: () =>
412 Ref.updateAndGet(lookupCount, (count) => count + 1).pipe(
413 Effect.tap((count) =>
414 count === 1
415 ? Deferred.succeed(firstLookupStarted, undefined)
416 : count === 3
417 ? Deferred.succeed(laterLookupStarted, undefined)
418 : Effect.void,
419 ),
420 Effect.tap((count) =>
421 count === 1
422 ? Deferred.await(releaseFirstLookup)
423 : count === 3
424 ? Deferred.await(releaseLaterLookup)
425 : Effect.void,
426 ),
427 Effect.andThen(Ref.get(state)),
428 Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })),
429 ),
430 });
431
432 yield* Effect.gen(function* () {
433 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
434 yield* reactor.start();
435 yield* Deferred.succeed(fixture.activation, undefined);
436 yield* Queue.take(fixture.snapshotReads);
437 yield* Deferred.await(firstLookupStarted);
438
439 yield* fixture.updateSettings({ sidebarAutoSettleOnMerge: false });
440 yield* Deferred.succeed(releaseFirstLookup, undefined);
441 yield* Queue.take(fixture.snapshotReads);
442 yield* reactor.drain;
443 assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
444 assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 2);
445
446 yield* Ref.set(state, "closed");
447 yield* fixture.updateSettings({ enableAgentBrowserAccess: false });
448 yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 1 });
449 yield* Deferred.await(laterLookupStarted);
450 yield* Deferred.succeed(releaseLaterLookup, undefined);
451 yield* reactor.drain;
452
453 assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 3);
454 assert.strictEqual(yield* Ref.get(lookupCount), 3);
455 assert.deepStrictEqual(
456 (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
457 [ThreadId.make("settings-thread")],
458 );
459 }).pipe(Effect.provide(fixture.layer));
460 }),
461 ),
462 );
463
464 it.effect("keeps an unknown pull request active and continues with other candidates", () =>
465 Effect.scoped(
466 Effect.gen(function* () {
467 yield* TestClock.setTime(Date.parse(NOW));
468 const fixture = yield* makeHarness({
469 snapshot: makeSnapshot(
470 [
471 makeThread("lookup-failed", {
472 linkedPullRequest: {
473 projectId: LINKED_PROJECT_ID,
474 repository: "owner/repository",
475 number: 9,
476 url: "https://example.test/owner/repository/pull/9",
477 },
478 }),
479 makeThread("inactive-without-pr"),
480 ],
481 [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
482 ),
483 pullRequestDetail: () =>
484 Effect.fail(
485 new PullRequestOperationError({
486 operation: "detail",
487 detail: "host unavailable",
488 }),
489 ),
490 });
491
492 yield* Effect.gen(function* () {
493 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
494 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
495
496 assert.deepStrictEqual(
497 (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
498 [ThreadId.make("inactive-without-pr")],
499 );
500 assert.strictEqual((yield* Ref.get(fixture.detailCalls)).length, 1);
501 }).pipe(Effect.provide(fixture.layer));
502 }),
503 ),
504 );
505
506 it.effect("keeps threads active when their pull request project is unavailable", () =>
507 Effect.scoped(
508 Effect.gen(function* () {
509 yield* TestClock.setTime(Date.parse(NOW));
510 const linkedPullRequest = {
511 projectId: LINKED_PROJECT_ID,
512 repository: "owner/repository",
513 number: 10,
514 url: "https://example.test/owner/repository/pull/10",
515 } as const;
516 const fixture = yield* makeHarness({
517 snapshot: makeSnapshot(
518 [
519 makeThread("missing-own-project", { linkedPullRequest }),
520 makeThread("missing-branch-project", { branch: "saved-feature" }),
521 ],
522 [makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
523 ),
524 pullRequestDetail: (input) =>
525 Effect.succeed(makePullRequestDetail({ ...input, state: "open" })),
526 });
527
528 yield* Effect.gen(function* () {
529 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
530 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
531
532 assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
533 assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [
534 { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 },
535 ]);
536 assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []);
537 }).pipe(Effect.provide(fixture.layer));
538 }),
539 ),
540 );
541
542 it.effect("deduplicates saved-branch and linked pull request lookups within a sweep", () =>
543 Effect.scoped(
544 Effect.gen(function* () {
545 yield* TestClock.setTime(Date.parse(NOW));
546 const linkedPullRequest = {
547 projectId: LINKED_PROJECT_ID,
548 repository: "owner/repository",
549 number: 77,
550 url: "https://example.test/owner/repository/pull/77",
551 } as const;
552 const fixture = yield* makeHarness({
553 snapshot: makeSnapshot(
554 [
555 makeThread("branch-one", {
556 branch: "saved-feature",
557 worktreePath: "/deleted/worktree-one",
558 }),
559 makeThread("branch-two", {
560 branch: "saved-feature",
561 worktreePath: "/deleted/worktree-two",
562 }),
563 makeThread("linked-one", { linkedPullRequest }),
564 makeThread("linked-two", { linkedPullRequest }),
565 ],
566 [
567 makeProject(PROJECT_ID, "/workspace/project-root"),
568 makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"),
569 ],
570 ),
571 branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }),
572 pullRequestDetail: (input) =>
573 Effect.succeed(makePullRequestDetail({ ...input, state: "merged" })),
574 });
575
576 yield* Effect.gen(function* () {
577 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
578 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
579
580 assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [
581 { cwd: "/workspace/project-root", branch: "saved-feature" },
582 ]);
583 assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [
584 { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 },
585 ]);
586 assert.deepStrictEqual(
587 new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)),
588 new Set([
589 ThreadId.make("branch-one"),
590 ThreadId.make("branch-two"),
591 ThreadId.make("linked-one"),
592 ThreadId.make("linked-two"),
593 ]),
594 );
595 }).pipe(Effect.provide(fixture.layer));
596 }),
597 ),
598 );
599
600 it.effect("carries the snapshot guard and survives a stale dispatch rejection", () =>
601 Effect.scoped(
602 Effect.gen(function* () {
603 yield* TestClock.setTime(Date.parse(NOW));
604 const fixture = yield* makeHarness({
605 snapshot: makeSnapshot([makeThread("stale"), makeThread("next-candidate")]),
606 onDispatch: (command) =>
607 command.threadId === ThreadId.make("stale")
608 ? Effect.fail(
609 new OrchestrationCommandInvariantError({
610 commandType: command.type,
611 detail: "thread changed after settlement evaluation",
612 }),
613 )
614 : Effect.void,
615 });
616
617 yield* Effect.gen(function* () {
618 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
619 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
620
621 const firstSweep = yield* Ref.get(fixture.commands);
622 assert.strictEqual(
623 firstSweep.find((command) => command.threadId === ThreadId.make("stale"))
624 ?.snapshotSequence,
625 1,
626 );
627 assert.strictEqual(
628 firstSweep.some((command) => command.threadId === ThreadId.make("next-candidate")),
629 true,
630 );
631
632 yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 });
633 yield* Queue.take(fixture.snapshotReads);
634 yield* reactor.drain;
635 assert.strictEqual((yield* Ref.get(fixture.commands)).length, 4);
636 }).pipe(Effect.provide(fixture.layer));
637 }),
638 ),
639 );
640});
apps/server/src/orchestration/ThreadSettlementReactor.ts created+185
......@@ -0,0 +1,185 @@
1import { CommandId } from "@t3tools/contracts";
2import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
3import * as Cause from "effect/Cause";
4import * as Context from "effect/Context";
5import * as Crypto from "effect/Crypto";
6import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Schedule from "effect/Schedule";
10import type * as Scope from "effect/Scope";
11import * as Stream from "effect/Stream";
12
13import * as GitManager from "../git/GitManager.ts";
14import * as PullRequestService from "../pullRequest/PullRequestService.ts";
15import * as ServerSettings from "../serverSettings.ts";
16import { forkParked } from "../serverActivation.ts";
17import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
18import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";
19import {
20 isAutoSettlementCandidate,
21 shouldAutoSettleThread,
22 type SettlementPullRequest,
23} from "./ThreadSettlementPolicy.ts";
24
25export class ThreadSettlementReactor extends Context.Service<
26 ThreadSettlementReactor,
27 {
28 readonly start: () => Effect.Effect<void, never, Scope.Scope>;
29 readonly drain: Effect.Effect<void>;
30 }
31>()("t3/orchestration/ThreadSettlementReactor") {}
32
33export const make = Effect.gen(function* () {
34 const engine = yield* OrchestrationEngine.OrchestrationEngineService;
35 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
36 const settingsService = yield* ServerSettings.ServerSettingsService;
37 const git = yield* GitManager.GitManager;
38 const pullRequests = yield* PullRequestService.PullRequestService;
39 const crypto = yield* Crypto.Crypto;
40
41 const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* () {
42 const snapshot = yield* snapshots.getShellSnapshot();
43 const now = DateTime.formatIso(yield* DateTime.now);
44 const projects = new Map(snapshot.projects.map((project) => [project.id, project]));
45 const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now));
46 const lookupKey = (thread: (typeof candidates)[number]) => {
47 if (thread.linkedPullRequest != null) {
48 return JSON.stringify([
49 "linked",
50 thread.linkedPullRequest.projectId,
51 thread.linkedPullRequest.repository,
52 thread.linkedPullRequest.number,
53 ]);
54 }
55 if (thread.branch === null) return JSON.stringify(["none", thread.id]);
56 const project = projects.get(thread.projectId);
57 return JSON.stringify(
58 project === undefined
59 ? ["missing-project", thread.id]
60 : ["branch", project.workspaceRoot, thread.branch],
61 );
62 };
63 const groups = Map.groupBy(candidates, lookupKey);
64
65 const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* (
66 thread: (typeof candidates)[number],
67 ) {
68 if (thread.linkedPullRequest != null) {
69 if (!projects.has(thread.linkedPullRequest.projectId)) {
70 return yield* Effect.die(new Error("linked pull request project not found"));
71 }
72 const detail = yield* pullRequests.detail({
73 projectId: thread.linkedPullRequest.projectId,
74 repository: thread.linkedPullRequest.repository,
75 number: thread.linkedPullRequest.number,
76 });
77 return { state: detail.state, updatedAt: detail.updatedAt } satisfies SettlementPullRequest;
78 }
79 if (thread.branch === null) return null;
80 const project = projects.get(thread.projectId);
81 if (project === undefined) {
82 return yield* Effect.die(new Error("thread project not found"));
83 }
84 return yield* git.branchPullRequest({ cwd: project.workspaceRoot, branch: thread.branch });
85 });
86
87 yield* Effect.forEach(
88 groups.values(),
89 (group) =>
90 Effect.gen(function* () {
91 const pullRequest = yield* pullRequestFor(group[0]!);
92 yield* Effect.forEach(
93 group,
94 (thread) =>
95 Effect.gen(function* () {
96 const settings = yield* settingsService.getSettings;
97 const decisionNow = DateTime.formatIso(yield* DateTime.now);
98 if (
99 !shouldAutoSettleThread({
100 thread,
101 pullRequest,
102 now: decisionNow,
103 autoSettleAfterDays: settings.sidebarAutoSettleAfterDays,
104 autoSettleOnMerge: settings.sidebarAutoSettleOnMerge,
105 })
106 ) {
107 return;
108 }
109 const uuid = yield* crypto.randomUUIDv4;
110 yield* engine.dispatch({
111 type: "thread.auto-settle",
112 commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`),
113 threadId: thread.id,
114 snapshotSequence: snapshot.snapshotSequence,
115 });
116 }).pipe(
117 Effect.catchCause((cause) =>
118 Cause.hasInterruptsOnly(cause)
119 ? Effect.failCause(cause)
120 : Effect.logWarning("automatic thread settlement skipped", {
121 threadId: thread.id,
122 cause: Cause.pretty(cause),
123 }),
124 ),
125 ),
126 { discard: true },
127 );
128 }).pipe(
129 Effect.catchCause((cause) =>
130 Cause.hasInterruptsOnly(cause)
131 ? Effect.failCause(cause)
132 : Effect.logWarning("automatic thread settlement skipped", {
133 threadIds: group.map((thread) => thread.id),
134 cause: Cause.pretty(cause),
135 }),
136 ),
137 ),
138 { concurrency: 8, discard: true },
139 );
140 });
141
142 const worker = yield* makeDrainableWorker(() =>
143 sweep().pipe(
144 Effect.catchCause((cause) =>
145 Cause.hasInterruptsOnly(cause)
146 ? Effect.failCause(cause)
147 : Effect.logWarning("automatic thread settlement sweep failed", {
148 cause: Cause.pretty(cause),
149 }),
150 ),
151 ),
152 );
153
154 const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn(
155 "ThreadSettlementReactor.start",
156 )(function* () {
157 const settingsChanges = yield* settingsService.subscribeChanges;
158 const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie);
159 let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays;
160 let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge;
161 yield* forkParked(
162 Effect.gen(function* () {
163 yield* worker.enqueue(undefined);
164 yield* worker.drain;
165 }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid),
166 );
167 yield* forkParked(
168 Stream.runForEach(settingsChanges, (settings) => {
169 if (
170 settings.sidebarAutoSettleAfterDays === lastAfterDays &&
171 settings.sidebarAutoSettleOnMerge === lastOnMerge
172 ) {
173 return Effect.void;
174 }
175 lastAfterDays = settings.sidebarAutoSettleAfterDays;
176 lastOnMerge = settings.sidebarAutoSettleOnMerge;
177 return worker.enqueue(undefined);
178 }),
179 );
180 });
181
182 return { start, drain: worker.drain } satisfies ThreadSettlementReactor["Service"];
183});
184
185export const layer = Layer.effect(ThreadSettlementReactor, make);
apps/server/src/orchestration/decider.settled.test.ts+44-7
......@@ -19,6 +19,8 @@ import { projectEvent } from "./projector.ts";
1919
2020const NOW = "2026-01-01T00:00:00.000Z";
2121const SETTLED_AT = "2025-12-30T00:00:00.000Z";
22const SETTLE_BLOCKED_MESSAGE =
23 "This thread still needs attention. Resolve or interrupt it first, then try again.";
2224
2325function makeReadModel(
2426 settledOverride: OrchestrationThread["settledOverride"],
......@@ -79,6 +81,22 @@ function makeSession(status: OrchestrationSession["status"]): OrchestrationSessi
7981}
8082
8183it.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
82100 it.effect("settles awake threads without a redundant wake and re-emits idempotently", () =>
83101 Effect.gen(function* () {
84102 const event = yield* decideOrchestrationCommand({
......@@ -198,7 +216,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
198216 },
199217 readModel: makeReadModel(null, null, makeSession(status)),
200218 }).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 });
202224 }
203225 // Stopped/error sessions are settleable — only live work is protected.
204226 const settled = yield* decideOrchestrationCommand({
......@@ -238,7 +260,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
238260 requestActivity("approval.requested", "req-1", NOW),
239261 ]),
240262 }).pipe(Effect.flip);
241 expect(openError._tag).toBe("OrchestrationCommandInvariantError");
263 expect(openError).toMatchObject({
264 _tag: "OrchestrationThreadSettleBlockedError",
265 threadId: ThreadId.make("thread-1"),
266 message: SETTLE_BLOCKED_MESSAGE,
267 });
242268
243269 // Same request later resolved: settleable again.
244270 const settled = yield* decideOrchestrationCommand({
......@@ -266,7 +292,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
266292 requestActivity("user-input.requested", "req-2", NOW),
267293 ]),
268294 }).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 });
270300 }),
271301 );
272302
......@@ -287,8 +317,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
287317 createdAt: NOW,
288318 }) as OrchestrationThread["activities"][number];
289319
290 // Stale-failure detail clears the request — mirrors the projection's
291 // pending accounting, which is what the client's canSettle sees.
320 // Stale-failure details clear the request, matching the projection flags.
292321 const settled = yield* decideOrchestrationCommand({
293322 command: {
294323 type: "thread.settle",
......@@ -324,7 +353,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
324353 }),
325354 ]),
326355 }).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 });
328361 }),
329362 );
330363
......@@ -352,7 +385,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
352385 },
353386 readModel: makeReadModel(null, null, null, [], [userMessage("1969-12-31T23:59:30.000Z")]),
354387 }).pipe(Effect.flip);
355 expect(queuedError._tag).toBe("OrchestrationCommandInvariantError");
388 expect(queuedError).toMatchObject({
389 _tag: "OrchestrationThreadSettleBlockedError",
390 threadId: ThreadId.make("thread-1"),
391 message: SETTLE_BLOCKED_MESSAGE,
392 });
356393
357394 // Message timestamp far in the FUTURE (client clock ahead of server):
358395 // 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 {
33 type OrchestrationCommand,
44 type OrchestrationEvent,
55 type OrchestrationReadModel,
6 type OrchestrationThread,
67} from "@t3tools/contracts";
78import * as DateTime from "effect/DateTime";
89import * as Crypto from "effect/Crypto";
910import * as Effect from "effect/Effect";
1011import type * as PlatformError from "effect/PlatformError";
1112
12import { OrchestrationCommandInvariantError } from "./Errors.ts";
13import {
14 OrchestrationCommandInvariantError,
15 OrchestrationThreadSettleBlockedError,
16 type OrchestrationCommandRejection,
17} from "./Errors.ts";
1318import {
1419 listThreadsByProjectId,
1520 requireActiveProjectWorkspaceRootAbsent,
......@@ -21,14 +26,10 @@ import {
2126 requireThreadNotArchived,
2227} from "./commandInvariants.ts";
2328import { projectEvent } from "./projector.ts";
29import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts";
2430
2531const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
2632
27// Session adoption takes seconds; a user message still unadopted after this
28// window is a failed/stale start, not pending work. Mirrors the client's
29// QUEUED_TURN_START_GRACE_MS in client-runtime threadSettled.ts.
30const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;
31
3233/**
3334 * Blocked-on-you work derived from the thread's retained activities: an
3435 * approval or user-input request with no later resolution for the same
......@@ -86,59 +87,28 @@ function hasOpenBlockingRequest(thread: {
8687 return openRequestIds.size > 0;
8788}
8889
89/**
90 * A queued turn start — a user message no turn has picked up yet — is work
91 * in flight even though session is still null (turn.start emits
92 * message-sent + turn-start-requested; the session arrives later). Detection
93 * mirrors the client's hasQueuedTurnStart: the newest user message is
94 * strictly newer than every latestTurn timestamp (adoption stamps the new
95 * turn's requestedAt with the message time, clearing this), and only within
96 * the adoption grace window — historical threads whose last user message
97 * postdates their turn timestamps (older-server data, mid-turn messages)
98 * must not be blocked forever. A failed session start (status "error")
99 * clears the block immediately.
100 *
101 * The age check is bounded on BOTH sides: message timestamps are
102 * client-supplied, so a client clock ahead of the server yields a negative
103 * age. Without the lower bound that negative age satisfies `<= grace` for
104 * as long as the skew lasts, extending the block far past the intended two
105 * minutes.
106 */
107function threadHasQueuedTurnStart(
108 thread: {
109 readonly messages: ReadonlyArray<{ readonly role: string; readonly createdAt: string }>;
110 readonly latestTurn: {
111 readonly requestedAt: string;
112 readonly startedAt: string | null;
113 readonly completedAt: string | null;
114 } | null;
115 readonly session: { readonly status: string } | null;
116 },
117 occurredAt: string,
90/** Apply the shared shell-level rule to the detailed command read model. */
91function hasQueuedTurnStartForThread(
92 thread: Pick<OrchestrationThread, "messages" | "latestTurn" | "session">,
93 now: string,
11894): boolean {
119 const latestUserMessageAtMs = thread.messages.reduce(
120 (latest, message) =>
121 message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest,
122 Number.NEGATIVE_INFINITY,
123 );
124 const latestTurnAtMs =
125 thread.latestTurn === null
126 ? Number.NEGATIVE_INFINITY
127 : Math.max(
128 ...[
129 thread.latestTurn.requestedAt,
130 thread.latestTurn.startedAt,
131 thread.latestTurn.completedAt,
132 ].map((candidate) =>
133 candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate),
134 ),
135 );
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
95 let latestUserMessageAt: string | null = null;
96 let latestUserMessageAtMs = Number.NEGATIVE_INFINITY;
97 for (const message of thread.messages) {
98 if (message.role !== "user") continue;
99 const messageAtMs = Date.parse(message.createdAt);
100 latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs);
101 if (messageAtMs === latestUserMessageAtMs) {
102 latestUserMessageAt = message.createdAt;
103 }
104 }
105 return threadHasQueuedTurnStart(
106 {
107 latestUserMessageAt: Number.isFinite(latestUserMessageAtMs) ? latestUserMessageAt : null,
108 latestTurn: thread.latestTurn,
109 session: thread.session,
110 },
111 now,
142112 );
143113}
144114
......@@ -186,7 +156,7 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({
186156 readonly readModel: OrchestrationReadModel;
187157}): Effect.fn.Return<
188158 ReadonlyArray<PlannedOrchestrationEvent>,
189 OrchestrationCommandInvariantError | PlatformError.PlatformError,
159 OrchestrationCommandRejection | PlatformError.PlatformError,
190160 Crypto.Crypto
191161> {
192162 let nextReadModel = readModel;
......@@ -220,7 +190,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
220190 readonly readModel: OrchestrationReadModel;
221191}): Effect.fn.Return<
222192 DecideOrchestrationCommandResult,
223 OrchestrationCommandInvariantError | PlatformError.PlatformError,
193 OrchestrationCommandRejection | PlatformError.PlatformError,
224194 Crypto.Crypto
225195> {
226196 switch (command.type) {
......@@ -450,43 +420,36 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
450420 };
451421 }
452422
453 case "thread.settle": {
423 case "thread.settle":
424 case "thread.auto-settle": {
454425 const thread = yield* requireThreadNotArchived({
455426 readModel,
456427 command,
457428 threadId: command.threadId,
458429 });
459 // Server-side twin of the client's canSettle session check: a stale
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") {
430 if (command.type === "thread.auto-settle" && thread.settledOverride !== null) {
463431 return yield* Effect.fail(
464432 new OrchestrationCommandInvariantError({
465433 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`,
467435 }),
468436 );
469437 }
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 }
470443 // Pending approval / user-input requests are blocked-on-you work: a
471444 // raced or stale client must not park them behind a settled override
472445 // that would surface only after the request resolves.
473446 if (hasOpenBlockingRequest(thread)) {
474 return yield* Effect.fail(
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 );
447 return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId });
480448 }
481449 const occurredAt = yield* nowIso;
482450 // Settling inside the adoption window would hide just-requested work.
483 if (threadHasQueuedTurnStart(thread, occurredAt)) {
484 return yield* Effect.fail(
485 new OrchestrationCommandInvariantError({
486 commandType: command.type,
487 detail: `thread ${command.threadId} has a queued turn start and cannot be settled`,
488 }),
489 );
451 if (hasQueuedTurnStartForThread(thread, occurredAt)) {
452 return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId });
490453 }
491454 // Settling an already-settled thread re-emits with the original
492455 // settledAt: the engine rejects zero-event commands, and bulk-settle /
......@@ -610,7 +573,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
610573 // invisible pending work: no session, no pending flags. Snoozing in
611574 // that window would hide a just-requested turn exactly the way settle
612575 // would.
613 if (threadHasQueuedTurnStart(thread, occurredAt)) {
576 if (hasQueuedTurnStartForThread(thread, occurredAt)) {
614577 return yield* Effect.fail(
615578 new OrchestrationCommandInvariantError({
616579 commandType: command.type,
......@@ -1152,7 +1115,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
11521115 if (
11531116 thread.settledOverride !== "settled" ||
11541117 sessionComingAlive ||
1155 threadHasQueuedTurnStart(thread, command.createdAt)
1118 hasQueuedTurnStartForThread(thread, command.createdAt)
11561119 ) {
11571120 return yield* Effect.fail(
11581121 new OrchestrationCommandInvariantError({
apps/server/src/persistence/Layers/OrchestrationEventStore.ts+12-11
......@@ -64,7 +64,7 @@ const OrchestrationEventPersistedRowSchema = Schema.Struct({
6464const HasEventAfterRequestSchema = Schema.Struct({
6565 aggregateKind: Schema.String,
6666 aggregateId: Schema.String,
67 type: Schema.String,
67 type: Schema.optional(Schema.String),
6868 sequenceExclusive: NonNegativeInt,
6969});
7070
......@@ -271,16 +271,17 @@ const makeEventStore = Effect.gen(function* () {
271271 const findEventAfter = SqlSchema.findOneOption({
272272 Request: HasEventAfterRequestSchema,
273273 Result: Schema.Struct({ sequence: Schema.Number }),
274 execute: (request) =>
275 sql`
276 SELECT sequence
277 FROM orchestration_events
278 WHERE aggregate_kind = ${request.aggregateKind}
279 AND stream_id = ${request.aggregateId}
280 AND event_type = ${request.type}
281 AND sequence > ${request.sequenceExclusive}
282 LIMIT 1
283 `,
274 execute: (request) => sql`
275 SELECT sequence
276 FROM orchestration_events
277 WHERE aggregate_kind = ${request.aggregateKind}
278 AND stream_id = ${request.aggregateId}
279 AND ${sql.and([
280 sql`sequence > ${request.sequenceExclusive}`,
281 ...(request.type === undefined ? [] : [sql`event_type = ${request.type}`]),
282 ])}
283 LIMIT 1
284 `,
284285 });
285286
286287 const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) =>
apps/server/src/persistence/Services/OrchestrationEventStore.ts+3-2
......@@ -54,7 +54,8 @@ export interface OrchestrationEventStoreShape {
5454 readonly readAll: () => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError>;
5555
5656 /**
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.
5859 *
5960 * Used during replay to tell whether a later event supersedes the one being
6061 * applied, without streaming the rest of the log.
......@@ -62,7 +63,7 @@ export interface OrchestrationEventStoreShape {
6263 readonly hasEventAfter: (input: {
6364 readonly aggregateKind: OrchestrationEvent["aggregateKind"];
6465 readonly aggregateId: string;
65 readonly type: OrchestrationEvent["type"];
66 readonly type?: OrchestrationEvent["type"];
6667 readonly sequenceExclusive: number;
6768 }) => Effect.Effect<boolean, OrchestrationEventStoreError>;
6869}
apps/server/src/server.test.ts+20-41
......@@ -118,7 +118,10 @@ import * as Keybindings from "./keybindings.ts";
118118import * as ExternalLauncher from "./process/externalLauncher.ts";
119119import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
120120import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
121import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts";
121import {
122 OrchestrationListenerCallbackError,
123 OrchestrationThreadSettleBlockedError,
124} from "./orchestration/Errors.ts";
122125import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
123126import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts";
124127import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
......@@ -8036,7 +8039,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
80368039 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
80378040 );
80388041
8039 it.effect("stops the provider session after settle without closing terminals", () =>
8042 it.effect("leaves settle cleanup to the event reactor", () =>
80408043 Effect.gen(function* () {
80418044 const threadId = ThreadId.make("thread-settle");
80428045 const effects: string[] = [];
......@@ -8094,64 +8097,40 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
80948097 );
80958098
80968099 assert.equal(dispatchResult.sequence, 1);
8097 assert.deepEqual(effects, ["dispatch:thread.settle", "dispatch:thread.session.stop"]);
8098 const sessionStopCommand = dispatchedCommands[1];
8099 assert.equal(sessionStopCommand?.type, "thread.session.stop");
8100 if (sessionStopCommand?.type === "thread.session.stop") {
8101 assert.equal(sessionStopCommand.threadId, threadId);
8102 assert.equal(sessionStopCommand.commandId, "session-stop-for-settle:cmd-thread-settle");
8103 assert.equal(sessionStopCommand.onlyIfSettled, true);
8104 }
8100 assert.deepEqual(effects, ["dispatch:thread.settle"]);
8101 assert.deepEqual(
8102 dispatchedCommands.map((command) => command.type),
8103 ["thread.settle"],
8104 );
81058105 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
81068106 );
81078107
8108 it.effect("settles without dispatching session stop when the thread has no session", () =>
8108 it.effect("forwards the friendly blocked-settlement message over websocket rpc", () =>
81098109 Effect.gen(function* () {
8110 const threadId = ThreadId.make("thread-settle-no-session");
8111 const effects: string[] = [];
8112 const dispatchedCommands: Array<OrchestrationCommand> = [];
8113
8110 const threadId = ThreadId.make("thread-settle-blocked");
81148111 yield* buildAppUnderTest({
81158112 layers: {
8116 terminalManager: {
8117 close: (input) =>
8118 Effect.sync(() => {
8119 effects.push(`terminal.close:${input.threadId}`);
8120 }),
8121 },
81228113 orchestrationEngine: {
8123 dispatch: (command) =>
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 ),
8114 dispatch: () => Effect.fail(new OrchestrationThreadSettleBlockedError({ threadId })),
81358115 },
81368116 },
81378117 });
81388118
81398119 const wsUrl = yield* getWsServerUrl("/ws");
8140 const dispatchResult = yield* Effect.scoped(
8120 const error = yield* Effect.scoped(
81418121 withWsRpcClient(wsUrl, (client) =>
81428122 client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
81438123 type: "thread.settle",
8144 commandId: CommandId.make("cmd-thread-settle-no-session"),
8124 commandId: CommandId.make("cmd-thread-settle-blocked"),
81458125 threadId,
81468126 }),
8147 ),
8127 ).pipe(Effect.flip),
81488128 );
81498129
8150 assert.equal(dispatchResult.sequence, 1);
8151 assert.deepEqual(effects, ["dispatch:thread.settle"]);
8152 assert.deepEqual(
8153 dispatchedCommands.map((command) => command.type),
8154 ["thread.settle"],
8130 assert.equal(error._tag, "OrchestrationDispatchCommandError");
8131 assert.equal(
8132 error.message,
8133 "This thread still needs attention. Resolve or interrupt it first, then try again.",
81558134 );
81568135 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
81578136 );
apps/server/src/server.ts+12-9
......@@ -62,6 +62,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun
6262import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";
6363import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
6464import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
65import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts";
6566import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
6667import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
6768import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
......@@ -257,6 +258,7 @@ const ReactorLayerLive = Layer.empty.pipe(
257258 Layer.provideMerge(ProviderCommandReactorLive),
258259 Layer.provideMerge(CheckpointReactorLive),
259260 Layer.provideMerge(ThreadDeletionReactorLive),
261 Layer.provideMerge(ThreadSettlementReactor.layer),
260262 Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))),
261263 Layer.provideMerge(RuntimeReceiptBusLive),
262264);
......@@ -290,6 +292,13 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay
290292 Layer.provideMerge(VcsDriverRegistryLayerLive),
291293);
292294
295const PullRequestServiceLive = PullRequestService.layer.pipe(
296 Layer.provide(PullRequestProviderRegistry.layer),
297 Layer.provide(SourceControlProviderRegistryLayerLive),
298 Layer.provide(SourceControlRateLimit.layer),
299 Layer.provide(VcsProcess.layer),
300);
301
293302const GitManagerLayerLive = GitManager.layer.pipe(
294303 Layer.provideMerge(ProjectSetupScriptRunner.layer),
295304 Layer.provideMerge(GitVcsDriver.layer),
......@@ -389,7 +398,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
389398 // Core Services
390399 Layer.provideMerge(ServerSettingsLayerLive),
391400 Layer.provideMerge(CheckpointingLayerLive),
392 Layer.provideMerge(SourceControlProviderRegistryLayerLive),
401 Layer.provideMerge(
402 Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLive),
403 ),
393404 Layer.provideMerge(GitLayerLive),
394405 Layer.provideMerge(VcsLayerLive),
395406 Layer.provideMerge(ProviderRuntimeLayerLive),
......@@ -458,14 +469,6 @@ const commandReadinessLayer = HttpRouter.middleware(
458469 { global: true },
459470);
460471
461const PullRequestServiceLive = PullRequestService.layer.pipe(
462 // One registry entry per supported host; the service only knows the registry.
463 Layer.provide(PullRequestProviderRegistry.layer),
464 Layer.provide(SourceControlProviderRegistryLayerLive),
465 Layer.provide(SourceControlRateLimit.layer),
466 Layer.provide(VcsProcess.layer),
467);
468
469472export const makeRoutesLayer = Layer.mergeAll(
470473 Layer.mergeAll(
471474 HttpApiBuilder.layer(EnvironmentHttpApi).pipe(
apps/server/src/serverSettings.test.ts+28
......@@ -272,6 +272,34 @@ it.layer(NodeServices.layer)("server settings", (it) => {
272272 ).pipe(Effect.provide(makeServerSettingsLayer())),
273273 );
274274
275 it.effect("persists and broadcasts thread settlement settings", () =>
276 Effect.scoped(
277 Effect.gen(function* () {
278 const serverConfig = yield* ServerConfig.ServerConfig;
279 const fileSystem = yield* FileSystem.FileSystem;
280 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
281 const changes = yield* serverSettings.subscribeChanges;
282
283 const next = yield* serverSettings.updateSettings({
284 sidebarAutoSettleAfterDays: null,
285 sidebarAutoSettleOnMerge: false,
286 });
287 const change = Option.getOrUndefined(yield* Stream.runHead(changes));
288 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
289 // Inspect raw persisted JSON before schema decoding can apply defaults.
290 // @effect-diagnostics-next-line preferSchemaOverJson:off
291 const persisted = JSON.parse(raw) as Record<string, unknown>;
292
293 assert.strictEqual(next.sidebarAutoSettleAfterDays, null);
294 assert.isFalse(next.sidebarAutoSettleOnMerge);
295 assert.strictEqual(change?.sidebarAutoSettleAfterDays, null);
296 assert.isFalse(change?.sidebarAutoSettleOnMerge);
297 assert.strictEqual(persisted.sidebarAutoSettleAfterDays, null);
298 assert.isFalse(persisted.sidebarAutoSettleOnMerge);
299 }),
300 ).pipe(Effect.provide(makeServerSettingsLayer())),
301 );
302
275303 it.effect("preserves model when switching providers via textGenerationModelSelection", () =>
276304 Effect.gen(function* () {
277305 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
apps/server/src/ws.ts+25-42
......@@ -1223,23 +1223,17 @@ const makeWsRpcLayer = (
12231223 ORCHESTRATION_WS_METHODS.dispatchCommand,
12241224 Effect.gen(function* () {
12251225 const normalizedCommand = yield* normalizeDispatchCommand(command);
1226 // Archive and settle both mean "done with this thread", so a
1227 // live provider session must not keep running background work
1228 // (PR monitors, dev servers, subagent fleets) after either
1229 // lands. The decider rejects settling a starting/running
1230 // session, so for settle this only ever stops an idle one; a
1231 // stopped session-set does not count as activity, so the stop
1232 // cannot un-settle the thread it follows.
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
1226 // Archive removes the thread from the client, so this transport
1227 // closes its session and terminals after the command lands.
1228 // Settlement cleanup is driven by thread.settled events in the
1229 // provider reactor, including settlements that have no client.
1230 const archiveCommand =
1231 normalizedCommand.type === "thread.archive" ? normalizedCommand : undefined;
1232 // Best-effort on purpose: the user's archive must not
12391233 // fail because this cleanup read blipped, so a failed read
12401234 // logs and skips the stop instead of propagating.
1241 const shouldStopSessionAfterCommand = parkingCommand
1242 ? yield* projectionSnapshotQuery.getThreadShellById(parkingCommand.threadId).pipe(
1235 const shouldStopSessionAfterCommand = archiveCommand
1236 ? yield* projectionSnapshotQuery.getThreadShellById(archiveCommand.threadId).pipe(
12431237 Effect.map(
12441238 Option.match({
12451239 onNone: () => false,
......@@ -1250,7 +1244,7 @@ const makeWsRpcLayer = (
12501244 Effect.catchCause((cause) =>
12511245 Effect.logWarning(
12521246 "failed to read thread session state before session-stop check",
1253 { threadId: parkingCommand.threadId, cause },
1247 { threadId: archiveCommand.threadId, cause },
12541248 ).pipe(Effect.as(false)),
12551249 ),
12561250 )
......@@ -1259,50 +1253,39 @@ const makeWsRpcLayer = (
12591253 Effect.tapError(() => cleanupFailedUploadedAttachments(command, normalizedCommand)),
12601254 );
12611255 yield* recordClientCommandAnalytics(normalizedCommand);
1262 if (parkingCommand) {
1263 const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle";
1256 if (archiveCommand) {
12641257 if (shouldStopSessionAfterCommand) {
12651258 yield* Effect.gen(function* () {
12661259 const stopCommand = yield* normalizeDispatchCommand({
12671260 type: "thread.session.stop",
12681261 commandId: CommandId.make(
1269 `session-stop-for-${parkingKind}:${parkingCommand.commandId}`,
1262 `session-stop-for-archive:${archiveCommand.commandId}`,
12701263 ),
1271 threadId: parkingCommand.threadId,
1264 threadId: archiveCommand.threadId,
12721265 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 } : {}),
12791266 });
12801267
12811268 yield* dispatchNormalizedCommand(stopCommand);
12821269 }).pipe(
12831270 Effect.catchCause((cause) =>
1284 Effect.logWarning(`failed to stop provider session during ${parkingKind}`, {
1285 threadId: parkingCommand.threadId,
1271 Effect.logWarning("failed to stop provider session during archive", {
1272 threadId: archiveCommand.threadId,
12861273 cause,
12871274 }),
12881275 ),
12891276 );
12901277 }
12911278
1292 // Terminals are user-opened panes, not thread background
1293 // work: archive removes the thread from view so they close
1294 // with it, but a settled thread stays reachable and may be
1295 // un-settled, so its terminals stay up.
1296 if (parkingCommand.type === "thread.archive") {
1297 yield* terminalManager.close({ threadId: parkingCommand.threadId }).pipe(
1298 Effect.catch((error) =>
1299 Effect.logWarning("failed to close thread terminals after archive", {
1300 threadId: parkingCommand.threadId,
1301 error: error.message,
1302 }),
1303 ),
1304 );
1305 }
1279 // Archive removes the thread from view, so its user-opened
1280 // terminal panes close with it.
1281 yield* terminalManager.close({ threadId: archiveCommand.threadId }).pipe(
1282 Effect.catch((error) =>
1283 Effect.logWarning("failed to close thread terminals after archive", {
1284 threadId: archiveCommand.threadId,
1285 error: error.message,
1286 }),
1287 ),
1288 );
13061289 }
13071290 return result;
13081291 }).pipe(
apps/web/src/components/ChatView.tsx+6-55
......@@ -30,12 +30,7 @@ import {
3030} from "@t3tools/client-runtime/connection";
3131import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
3232import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
33import {
34 changeRequestAutoSettles,
35 effectiveSettled,
36 effectiveSnoozed,
37 threadWokeAt,
38} from "@t3tools/client-runtime/state/thread-settled";
33import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
3934import {
4035 codexFeedbackMessage,
4136 parseCodexFeedbackCommand,
......@@ -4539,9 +4534,7 @@ function ChatViewContent(props: ChatViewProps) {
45394534 : null,
45404535 [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread],
45414536 );
4542 // Settled state of the open thread, resolved exactly like the sidebar
4543 // partition (same shell, same capability gate, same PR auto-settle input)
4544 // so the banner and the sidebar row never disagree.
4537 // The server-projected settled state keeps the banner and sidebar in sync.
45454538 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
45464539 const activeComposerTasksProgress = useMemo(() => {
45474540 if (!activeLatestTurn || latestTurnSettled || activePlan?.turnId !== activeLatestTurn.turnId) {
......@@ -4561,7 +4554,6 @@ function ChatViewContent(props: ChatViewProps) {
45614554 activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId
45624555 ? activePlan.steps
45634556 : null;
4564
45654557 useLayoutEffect(() => {
45664558 if (!composerOverlayElement) return;
45674559
......@@ -4585,9 +4577,6 @@ function ChatViewContent(props: ChatViewProps) {
45854577 resizeObserver.disconnect();
45864578 };
45874579 }, [composerOverlayElement]);
4588
4589 const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);
4590 const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);
45914580 const linkedPullRequestStatus = useLinkedThreadPullRequest(
45924581 activeThreadRef?.environmentId ?? null,
45934582 linkedThreadPullRequest,
......@@ -4608,18 +4597,6 @@ function ChatViewContent(props: ChatViewProps) {
46084597 }, [activeThreadPr, openThreadPullRequest]);
46094598 const pullRequestSurfaceAvailable =
46104599 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 );
46234600 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
46244601 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
46254602 const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true;
......@@ -4650,21 +4627,13 @@ function ChatViewContent(props: ChatViewProps) {
46504627 if (activeThreadRef === null || activeThreadWokeAt === null) return;
46514628 markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
46524629 }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
4653 // Mirror of the sidebar's Woke pill for the open thread. It uses the same
4654 // visit comparison and change request settle rule.
4630 // Mirror of the sidebar's Woke pill for the open thread.
46554631 const activeThreadLastVisitedAt = useUiStateStore((store) =>
46564632 activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey],
46574633 );
46584634 const activeThreadWokeVisible = useMemo(() => {
46594635 if (activeThreadWokeAt === null) return false;
4660 if (
4661 changeRequestAutoSettles(activeThreadChangeRequest, {
4662 autoSettleOnMerge,
4663 thread: activeThreadShell,
4664 })
4665 ) {
4666 return false;
4667 }
4636 if (activeThreadShell?.settledOverride === "settled") return false;
46684637 const wokeAtMs = Date.parse(activeThreadWokeAt);
46694638 if (Number.isNaN(wokeAtMs)) return false;
46704639 // Having the thread open counts as a visit at completedAt (the effect
......@@ -4684,28 +4653,11 @@ function ChatViewContent(props: ChatViewProps) {
46844653 }, [
46854654 activeLatestTurn?.completedAt,
46864655 activeThreadLastVisitedAt,
4687 activeThreadChangeRequest,
46884656 activeThreadShell,
46894657 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,
47084658 ]);
4659 const activeThreadSettled =
4660 supportsSettlement && activeThreadShell?.settledOverride === "settled";
47094661 const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, {
47104662 reportFailure: false,
47114663 });
......@@ -7158,7 +7110,6 @@ function ChatViewContent(props: ChatViewProps) {
71587110 {...(routeKind === "draft" && draftId ? { draftId } : {})}
71597111 activeThreadTitle={activeThread.title}
71607112 isServerThread={isServerThread}
7161 changeRequest={activeThreadChangeRequest}
71627113 activeProjectName={activeProject?.title}
71637114 activeProjectCwd={activeProject?.workspaceRoot ?? null}
71647115 activeProjectFaviconPath={activeProject?.faviconPath ?? null}
apps/web/src/components/Sidebar.logic.ts+2-5
......@@ -618,11 +618,8 @@ type SettledTimestampInput = Pick<
618618 "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt"
619619>;
620620
621/** The timestamp a settled row sorts and labels by: settledAt when stamped
622 (explicit settles), otherwise last activity — the same candidates
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. */
621/** The timestamp a settled row sorts and labels by: settledAt when stamped,
622 otherwise the latest message or turn stamp. updatedAt is the final net. */
626623export function resolveSettledTimestamp(thread: SettledTimestampInput): string | null {
627624 const settledAt = firstValidTimestamp(thread.settledAt);
628625 if (settledAt !== null) return settledAt;
apps/web/src/components/Sidebar.tsx+6-49
......@@ -19,8 +19,6 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-
1919import { CSS } from "@dnd-kit/utilities";
2020import {
2121 canSnooze,
22 changeRequestAutoSettles,
23 effectiveSettled,
2422 effectiveSnoozed,
2523 threadWokeAt,
2624} from "@t3tools/client-runtime/state/thread-settled";
......@@ -715,7 +713,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
715713 // False on environments whose server predates thread.settle/unsettle:
716714 // the lifecycle affordances hide entirely rather than fail on click.
717715 settlementSupported: boolean;
718 autoSettleOnMerge: boolean;
719716 // Same contract for thread.snooze/unsnooze.
720717 snoozeSupported: boolean;
721718 // Pinned threads show the same pin marker in active, settled, and snoozed
......@@ -840,10 +837,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
840837 const isWoke =
841838 wokeAtDate !== null &&
842839 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
843 !changeRequestAutoSettles(pr, {
844 autoSettleOnMerge: props.autoSettleOnMerge,
845 thread,
846 });
840 thread.settledOverride !== "settled";
847841 // In-flight rows (working, or waiting on approval/input) fade as a whole:
848842 // there is nothing for the user to do yet, so prominence is reserved for
849843 // rows that need a human — done (unread), read-but-unsettled, failed, and
......@@ -1740,8 +1734,6 @@ export default function Sidebar() {
17401734 const router = useRouter();
17411735 const { isMobile, setOpenMobile } = useSidebar();
17421736 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
1743 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
1744 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);
17451737 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
17461738 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
17471739 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
......@@ -1938,8 +1930,6 @@ export default function Sidebar() {
19381930 [projectGroups],
19391931 );
19401932
1941 // now is quantized to the minute so effectiveSettled memoization doesn't
1942 // churn on every render; auto-settle thresholds are day-granular anyway.
19431933 const nowMinute = useNowMinute();
19441934 // Snooze wake times are second-precise, so classifying with the quantized
19451935 // minute would hold a woken thread on the shelf for up to a minute. The
......@@ -2081,7 +2071,6 @@ export default function Sidebar() {
20812071 settledThreads,
20822072 snoozeNow,
20832073 } = useMemo(() => {
2084 const now = `${nowMinute}:00.000Z`;
20852074 // Snooze classification uses a REAL clock, not the quantized minute:
20862075 // wake times are second-precise and a woken thread must not linger on
20872076 // the shelf for the rest of the minute. snoozeWakeTick re-runs this
......@@ -2107,29 +2096,10 @@ export default function Sidebar() {
21072096 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true;
21082097 const supportsSnooze =
21092098 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;
21212099 // Snooze outranks settlement and pinning until the thread wakes.
21222100 if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
21232101 snoozed.push(thread);
2124 } else if (
2125 supportsSettlement &&
2126 effectiveSettled(thread, {
2127 now,
2128 autoSettleAfterDays,
2129 autoSettleOnMerge,
2130 changeRequest,
2131 })
2132 ) {
2102 } else if (supportsSettlement && thread.settledOverride === "settled") {
21332103 settled.push(thread);
21342104 } else if (thread.pinnedAt != null) {
21352105 pinned.push(thread);
......@@ -2163,16 +2133,7 @@ export default function Sidebar() {
21632133 settledThreads: sortSettledThreadsForSidebar(settled),
21642134 snoozeNow: preciseNow,
21652135 };
2166 }, [
2167 autoSettleAfterDays,
2168 autoSettleOnMerge,
2169 changeRequestSnapshotByKey,
2170 nowMinute,
2171 scopedProjectKeys,
2172 serverConfigs,
2173 snoozeWakeTick,
2174 threads,
2175 ]);
2136 }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]);
21762137
21772138 const threadSearchInputRef = useRef<HTMLInputElement>(null);
21782139 const [threadSearchQuery, setThreadSearchQuery] = useState("");
......@@ -3111,9 +3072,8 @@ export default function Sidebar() {
31113072 thread.worktreePath ??
31123073 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
31133074 null;
3114 // Un-settle works on every settled row: for explicit settles it
3115 // clears the override, for auto-settled rows it pins the thread
3116 // active until real activity clears the pin. Environments without
3075 // Un-settle pins the thread active until real activity clears the pin.
3076 // Environments without
31173077 // the settlement capability get no lifecycle items at all.
31183078 const supportsSettlement =
31193079 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement ===
......@@ -3777,9 +3737,7 @@ export default function Sidebar() {
37773737 key={`${threadKey}:${rowVariant}`}
37783738 thread={thread}
37793739 variant={rowVariant}
3780 // Snoozed rows wake; settled rows un-settle (explicit
3781 // settles clear the override, auto-settled rows get
3782 // pinned active); cards settle.
3740 // Snoozed rows wake, settled rows un-settle, and cards settle.
37833741 variantAction={
37843742 section === "snoozed"
37853743 ? "unsnooze"
......@@ -3791,7 +3749,6 @@ export default function Sidebar() {
37913749 serverConfigs.get(thread.environmentId)?.environment.capabilities
37923750 .threadSettlement === true
37933751 }
3794 autoSettleOnMerge={autoSettleOnMerge}
37953752 snoozeSupported={
37963753 serverConfigs.get(thread.environmentId)?.environment.capabilities
37973754 .threadSnooze === true
apps/web/src/components/ThreadStatusIndicators.test.ts+2-34
......@@ -1,6 +1,4 @@
1import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";
2import type { OrchestrationThreadShell } from "@t3tools/contracts";
3import { ProjectId, ProviderInstanceId, ThreadId, type VcsStatusResult } from "@t3tools/contracts";
1import { ProjectId, type VcsStatusResult } from "@t3tools/contracts";
42import { describe, expect, it } from "@effect/vitest";
53import * as Effect from "effect/Effect";
64import { AtomRegistry } from "effect/unstable/reactivity";
......@@ -495,7 +493,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
495493 ).toEqual(mergedPr);
496494 });
497495
498 it("keeps effectiveSettled true for a retained merged PR after a main checkout", () => {
496 it("retains a merged PR after a main checkout", () => {
499497 const matchingStatus = status({
500498 refName: featureBranch,
501499 pr: mergedPr,
......@@ -518,36 +516,6 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
518516 retainTerminalOnBranchMismatch: true,
519517 });
520518 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);
551519 });
552520});
553521
apps/web/src/components/chat/ChatHeader.tsx-5
......@@ -10,7 +10,6 @@ import {
1010 isAtomCommandInterrupted,
1111 squashAtomCommandFailure,
1212} from "@t3tools/client-runtime/state/runtime";
13import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
1413import { ChevronDownIcon } from "lucide-react";
1514import {
1615 memo,
......@@ -53,8 +52,6 @@ interface ChatHeaderProps {
5352 activeThreadTitle: string;
5453 /** Drafts have no server thread yet, so the title carries no action menu. */
5554 isServerThread: boolean;
56 /** PR feeding the settled classification, resolved by ChatView. */
57 changeRequest: ChangeRequestSettleSource | null;
5855 activeProjectName: string | undefined;
5956 activeProjectCwd: string | null;
6057 activeProjectFaviconPath: string | null;
......@@ -123,7 +120,6 @@ export const ChatHeader = memo(function ChatHeader({
123120 draftId,
124121 activeThreadTitle,
125122 isServerThread,
126 changeRequest,
127123 activeProjectName,
128124 activeProjectCwd,
129125 activeProjectFaviconPath,
......@@ -201,7 +197,6 @@ export const ChatHeader = memo(function ChatHeader({
201197 const { openMenu, closeMenu } = useThreadActionMenu({
202198 threadRef: isServerThread ? activeThreadRef : null,
203199 projectCwd: activeProjectCwd,
204 changeRequest,
205200 onStartRename: startRename,
206201 });
207202 const titleButtonRef = useRef<HTMLButtonElement | null>(null);
apps/web/src/components/settings/SettingsPanels.tsx+72-61
......@@ -79,7 +79,11 @@ import {
7979} from "../../providerInstances";
8080import { ensureLocalApi, readLocalApi } from "../../localApi";
8181import { isMacPlatform } from "../../lib/utils";
82import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server";
82import {
83 primaryServerConfigAtom,
84 primaryServerObservabilityAtom,
85 primaryServerProvidersAtom,
86} from "../../state/server";
8387import { useProjects } from "../../state/entities";
8488import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
8589import { formatRelativeTimeLabel } from "../../timestampFormat";
......@@ -1871,6 +1875,8 @@ export function GeneralSettingsPanel() {
18711875 );
18721876 const observability = useAtomValue(primaryServerObservabilityAtom);
18731877 const serverProviders = useAtomValue(primaryServerProvidersAtom);
1878 const supportsAutoSettlement =
1879 useAtomValue(primaryServerConfigAtom)?.environment.capabilities.threadAutoSettlement === true;
18741880 const diagnosticsDescription = formatDiagnosticsDescription({
18751881 localTracingEnabled: observability?.localTracingEnabled ?? false,
18761882 otlpTracesEnabled: observability?.otlpTracesEnabled ?? false,
......@@ -1954,72 +1960,77 @@ export function GeneralSettingsPanel() {
19541960 }
19551961 />
19561962
1957 <SettingsRow
1958 {...searchableSetting("auto-settle-merged-threads")}
1959 description="Settle a thread when its pull request merges. Closed pull requests still settle automatically."
1960 resetAction={
1961 settings.sidebarAutoSettleOnMerge !==
1962 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge ? (
1963 <SettingResetButton
1964 label="auto-settle on merge"
1965 onClick={() =>
1966 updateSettings({
1967 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
1968 })
1969 }
1970 />
1971 ) : null
1972 }
1973 control={
1974 <Switch
1975 checked={settings.sidebarAutoSettleOnMerge}
1976 onCheckedChange={(checked) =>
1977 updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) })
1963 {supportsAutoSettlement ? (
1964 <>
1965 <SettingsRow
1966 {...searchableSetting("auto-settle-merged-threads")}
1967 description="Settle a thread when its pull request merges. Closed pull requests still settle automatically."
1968 resetAction={
1969 settings.sidebarAutoSettleOnMerge !==
1970 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge ? (
1971 <SettingResetButton
1972 label="auto-settle on merge"
1973 onClick={() =>
1974 updateSettings({
1975 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
1976 })
1977 }
1978 />
1979 ) : null
1980 }
1981 control={
1982 <Switch
1983 checked={settings.sidebarAutoSettleOnMerge}
1984 onCheckedChange={(checked) =>
1985 updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) })
1986 }
1987 aria-label="Auto-settle merged threads"
1988 />
19781989 }
1979 aria-label="Auto-settle merged threads"
19801990 />
1981 }
1982 />
19831991
1984 <SettingsRow
1985 {...searchableSetting("auto-settle-inactive-threads")}
1986 description="Sidebar threads with no activity for this long settle automatically."
1987 resetAction={
1988 settings.sidebarAutoSettleAfterDays !==
1989 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? (
1990 <SettingResetButton
1991 label="auto-settle"
1992 onClick={() =>
1993 updateSettings({
1994 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
1995 })
1996 }
1997 />
1998 ) : null
1999 }
2000 control={
2001 <Switch
2002 checked={settings.sidebarAutoSettleAfterDays !== null}
2003 onCheckedChange={(checked) =>
2004 updateSettings({
2005 sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null,
2006 })
1992 <SettingsRow
1993 {...searchableSetting("auto-settle-inactive-threads")}
1994 description="Sidebar threads with no activity for this long settle automatically."
1995 resetAction={
1996 settings.sidebarAutoSettleAfterDays !==
1997 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? (
1998 <SettingResetButton
1999 label="auto-settle"
2000 onClick={() =>
2001 updateSettings({
2002 sidebarAutoSettleAfterDays:
2003 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
2004 })
2005 }
2006 />
2007 ) : null
2008 }
2009 control={
2010 <Switch
2011 checked={settings.sidebarAutoSettleAfterDays !== null}
2012 onCheckedChange={(checked) =>
2013 updateSettings({
2014 sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null,
2015 })
2016 }
2017 aria-label="Auto-settle inactive threads"
2018 />
20072019 }
2008 aria-label="Auto-settle inactive threads"
20092020 />
2010 }
2011 />
2012 {settings.sidebarAutoSettleAfterDays !== null ? (
2013 <SettingsRow
2014 title={searchableSetting("days-before-auto-settle").title}
2015 description="Any new activity un-settles a thread automatically."
2016 control={
2017 <AutoSettleDaysInput
2018 value={settings.sidebarAutoSettleAfterDays}
2019 onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}
2021 {settings.sidebarAutoSettleAfterDays !== null ? (
2022 <SettingsRow
2023 title={searchableSetting("days-before-auto-settle").title}
2024 description="Any new activity un-settles a thread automatically."
2025 control={
2026 <AutoSettleDaysInput
2027 value={settings.sidebarAutoSettleAfterDays}
2028 onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}
2029 />
2030 }
20202031 />
2021 }
2022 />
2032 ) : null}
2033 </>
20232034 ) : null}
20242035
20252036 <SettingsRow
apps/web/src/components/settings/settingsSearch.test.ts+22-1
......@@ -104,7 +104,7 @@ describe("searchSettings", () => {
104104
105105 it("hides desktop-only settings from browser search", () => {
106106 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([]);
108108 expect(searchSettings("wsl")).toEqual([]);
109109 });
110110
......@@ -134,6 +134,7 @@ describe("searchSettings", () => {
134134 hasProviderSettingsEnvironment: false,
135135 canManageLocalBackend: false,
136136 isWslSettingsRowVisible: false,
137 hasThreadAutoSettlement: false,
137138 });
138139
139140 const gatedIds = new Set<string>([
......@@ -147,10 +148,30 @@ describe("searchSettings", () => {
147148 "t3-connect",
148149 "tailscale-https",
149150 "wsl-backend",
151 "auto-settle-inactive-threads",
152 "auto-settle-merged-threads",
153 "days-before-auto-settle",
150154 ]);
151155 expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]);
152156 });
153157
158 it("shows automatic settlement settings when the server supports them", () => {
159 const available = filterAvailableSettingsSearchItems({
160 hasCloudPublicConfig: false,
161 hasPrimaryEnvironment: false,
162 hasProviderSettingsEnvironment: false,
163 canManageLocalBackend: false,
164 isWslSettingsRowVisible: false,
165 hasThreadAutoSettlement: true,
166 });
167
168 expect(searchSettings("auto-settle", available).map((item) => item.id)).toEqual([
169 "auto-settle-inactive-threads",
170 "auto-settle-merged-threads",
171 "days-before-auto-settle",
172 ]);
173 });
174
154175 it("keeps catalog result ids unique", () => {
155176 const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);
156177 expect(new Set(ids).size).toBe(ids.length);
apps/web/src/components/settings/settingsSearch.ts+7-1
......@@ -30,6 +30,7 @@ export interface SettingsSearchItem {
3030 readonly providerSettingsOnly?: boolean;
3131 readonly localBackendManagementOnly?: boolean;
3232 readonly wslAvailableOnly?: boolean;
33 readonly requiresThreadAutoSettlement?: boolean;
3334}
3435
3536export interface SettingsSearchAvailability {
......@@ -38,6 +39,7 @@ export interface SettingsSearchAvailability {
3839 readonly hasProviderSettingsEnvironment: boolean;
3940 readonly canManageLocalBackend: boolean;
4041 readonly isWslSettingsRowVisible: boolean;
42 readonly hasThreadAutoSettlement: boolean;
4143}
4244
4345/**
......@@ -148,12 +150,14 @@ export const SETTINGS_SEARCH_ITEMS = [
148150 title: "Auto-settle inactive threads",
149151 to: "/settings/general",
150152 searchTerms: ["sidebar inactivity days no activity automatically"],
153 requiresThreadAutoSettlement: true,
151154 },
152155 {
153156 id: "auto-settle-merged-threads",
154157 title: "Auto-settle merged threads",
155158 to: "/settings/general",
156159 searchTerms: ["pull request merge closed automatically sidebar"],
160 requiresThreadAutoSettlement: true,
157161 },
158162 {
159163 id: "days-before-auto-settle",
......@@ -161,6 +165,7 @@ export const SETTINGS_SEARCH_ITEMS = [
161165 to: "/settings/general",
162166 targetId: "auto-settle-inactive-threads",
163167 searchTerms: ["thread timeout activity sidebar"],
168 requiresThreadAutoSettlement: true,
164169 },
165170 {
166171 id: "time-format",
......@@ -461,7 +466,8 @@ export function filterAvailableSettingsSearchItems(
461466 (!item.primaryOnly || availability.hasPrimaryEnvironment) &&
462467 (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) &&
463468 (!item.localBackendManagementOnly || availability.canManageLocalBackend) &&
464 (!item.wslAvailableOnly || availability.isWslSettingsRowVisible),
469 (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) &&
470 (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement),
465471 );
466472}
467473
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+13-1
......@@ -1,4 +1,5 @@
11import { useMemo } from "react";
2import { useAtomValue } from "@effect/atom-react";
23import { AuthAccessWriteScope } from "@t3tools/contracts";
34
45import { hasCloudPublicConfig } from "~/cloud/publicConfig";
......@@ -7,6 +8,7 @@ import { desktopWslStateAtom } from "~/state/desktopWslState";
78import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
89import { useEnvironmentQuery } from "~/state/query";
910import { usePrimarySessionState } from "~/environments/primary";
11import { primaryServerConfigAtom } from "~/state/server";
1012import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
1113import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";
1214import { filterAvailableSettingsSearchItems } from "./settingsSearch";
......@@ -15,6 +17,7 @@ export function useAvailableSettingsSearchItems() {
1517 const primaryEnvironmentId = usePrimaryEnvironmentId();
1618 const { environments } = useEnvironments();
1719 const primarySessionState = usePrimarySessionState();
20 const primaryServerConfig = useAtomValue(primaryServerConfigAtom);
1821 const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null);
1922 const canManageLocalBackend =
2023 isElectron ||
......@@ -38,7 +41,16 @@ export function useAvailableSettingsSearchItems() {
3841 state: desktopWsl.data,
3942 error: desktopWsl.error,
4043 }),
44 hasThreadAutoSettlement:
45 primaryServerConfig?.environment.capabilities.threadAutoSettlement === true,
4146 }),
42 [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments, primaryEnvironmentId],
47 [
48 canManageLocalBackend,
49 desktopWsl.data,
50 desktopWsl.error,
51 environments,
52 primaryEnvironmentId,
53 primaryServerConfig,
54 ],
4355 );
4456}
apps/web/src/hooks/useNowMinute.ts+2-5
......@@ -1,10 +1,7 @@
11import { useSyncExternalStore } from "react";
22
3/** Minute-quantized clock ("YYYY-MM-DDTHH:MM") for settled-state resolution.
4 One module-level timer 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. */
3/** Minute-quantized UI clock ("YYYY-MM-DDTHH:MM"). One module-level timer
4 feeds every consumer through useSyncExternalStore. */
85
96function currentMinute(): string {
107 return new Date().toISOString().slice(0, 16);
apps/web/src/hooks/useSettings.test.ts+18
......@@ -76,4 +76,22 @@ describe("mergeEnvironmentSettings", () => {
7676 expect(settings.providerInstances).toBe(serverSettings.providerInstances);
7777 expect(settings.favorites).toBe(clientSettings.favorites);
7878 });
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 });
7997});
apps/web/src/hooks/useSettings.ts+3-1
......@@ -216,7 +216,9 @@ export function mergeEnvironmentSettings(
216216 serverSettings: ServerSettings,
217217 clientSettings: ClientSettings,
218218): 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 };
220222}
221223
222224function useMergedSettings<T>(
apps/web/src/hooks/useThreadActionMenu.ts+3-25
......@@ -5,12 +5,7 @@ import {
55 settlePromise,
66 squashAtomCommandFailure,
77} from "@t3tools/client-runtime/state/runtime";
8import {
9 canSnooze,
10 effectiveSettled,
11 effectiveSnoozed,
12 type ChangeRequestSettleSource,
13} from "@t3tools/client-runtime/state/thread-settled";
8import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
149import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";
1510import { useCallback } from "react";
1611
......@@ -60,11 +55,9 @@ export function useThreadActionMenu(input: {
6055 readonly threadRef: ScopedThreadRef | null;
6156 /** Fallback for "Copy path" when the thread has no worktree. */
6257 readonly projectCwd: string | null;
63 /** PR feeding auto-settle classification, as resolved by the caller. */
64 readonly changeRequest: ChangeRequestSettleSource | null;
6558 readonly onStartRename: () => void;
6659}) {
67 const { threadRef, projectCwd, changeRequest, onStartRename } = input;
60 const { threadRef, projectCwd, onStartRename } = input;
6861 const {
6962 settleThread,
7063 unsettleThread,
......@@ -80,8 +73,6 @@ export function useThreadActionMenu(input: {
8073 });
8174 const handleNewThread = useNewThreadHandler();
8275 const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
83 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
84 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);
8576 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
8677 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
8778 const timestampFormat = useClientSettings((s) => s.timestampFormat);
......@@ -127,17 +118,7 @@ export function useThreadActionMenu(input: {
127118 const items = buildThreadActionMenuItems({
128119 branch: thread.branch ?? null,
129120 isPinned: thread.pinnedAt != null,
130 isSettled:
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 }),
121 isSettled: supports.settlement && thread.settledOverride === "settled",
141122 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
142123 canSnoozeNow: canSnooze(thread, { now: now.toISOString() }),
143124 isRegeneratingTitle,
......@@ -311,9 +292,6 @@ export function useThreadActionMenu(input: {
311292 },
312293 [
313294 archiveThread,
314 autoSettleAfterDays,
315 autoSettleOnMerge,
316 changeRequest,
317295 confirmThreadArchive,
318296 confirmThreadDelete,
319297 confirmAndUnpinThread,
apps/web/src/hooks/useThreadActions.ts+1-26
......@@ -5,7 +5,7 @@ import {
55 scopedThreadKey,
66} from "@t3tools/client-runtime/environment";
77import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
8import { canSettle, canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
8import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
99import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
1010import * as Cause from "effect/Cause";
1111import * as Schema from "effect/Schema";
......@@ -64,18 +64,6 @@ export class ThreadSettlementUnsupportedError extends Schema.TaggedErrorClass<Th
6464 }
6565}
6666
67export class ThreadSettleBlockedError extends Schema.TaggedErrorClass<ThreadSettleBlockedError>()(
68 "ThreadSettleBlockedError",
69 {
70 environmentId: EnvironmentId,
71 threadId: ThreadId,
72 },
73) {
74 override get message(): string {
75 return "This thread still needs attention. Resolve or interrupt it first, then try again.";
76 }
77}
78
7967export class ThreadSnoozeUnsupportedError extends Schema.TaggedErrorClass<ThreadSnoozeUnsupportedError>()(
8068 "ThreadSnoozeUnsupportedError",
8169 {
......@@ -506,19 +494,6 @@ export function useThreadActions() {
506494 );
507495 }
508496 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 }
522497 const wokeAt = resolved
523498 ? threadWokeAt(resolved.thread, { now: new Date().toISOString() })
524499 : null;
docs/internals/overview.md+15-3
......@@ -88,17 +88,28 @@ A turn is complete when its session leaves `running` status, projected by
8888`settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later
8989does not define turn end.
9090
91Thread settlement is server-owned. Per-environment settings control PR and inactivity settlement.
92[`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and
93once per minute, including when no client is connected. It dispatches the guarded internal
94`thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic
95settlement excludes live background work and requires a comparable PR timestamp for immediate PR
96settlement. The command also rejects any later event for its thread after the reactor's snapshot.
97Clients render the persisted settlement state and do not derive settlement from PR or inactivity
98state. A committed `thread.settled` event also lets `ProviderCommandReactor` stop an idle provider
99session.
100
91101## Drainable workers
92102
93103Follow-up work runs asynchronously in queue-backed workers built on [`DrainableWorker`][worker]:
94104[`ProviderRuntimeIngestion`][ingest] normalizes provider runtime streams into orchestration commands,
95[`ProviderCommandReactor`][cmd] dispatches provider calls in response to intent events, and
96[`CheckpointReactor`][checkpoint] captures and reverts workspace checkpoints.
105[`ProviderCommandReactor`][cmd] dispatches provider calls in response to intent events,
106[`CheckpointReactor`][checkpoint] captures and reverts workspace checkpoints, and
107[`ThreadSettlementReactor`][settlement] evaluates server-owned automatic settlement rules.
97108
98109`DrainableWorker` pairs a transactional queue with a transactional count of outstanding items.
99110`enqueue` atomically offers and increments; processing always decrements. `drain` retries until the
100111count reaches zero, so a test can await "queue empty and current item finished" instead of sleeping.
101Each of the three services exposes `drain` for exactly this.
112Each of these four services exposes `drain` for exactly this.
102113
103114Runtime receipts are a test-only mechanism. `RuntimeReceiptBusLive` in
104115[`RuntimeReceiptBus.ts`][receipts] publishes nothing; only the test layer is PubSub-backed. Do not
......@@ -150,5 +161,6 @@ already dispatch.
150161[ingest]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
151162[cmd]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
152163[checkpoint]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts
164[settlement]: ../../apps/server/src/orchestration/ThreadSettlementReactor.ts
153165[receipts]: ../../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts
154166[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
1010Pinned threads still move to **Settled** when they become inactive. They also move when their pull
1111request merges if **Auto-settle merged threads** is enabled.
1212
13Each environment owns its automatic settlement settings. The server checks them even when no web,
14desktop, or mobile client is connected. By default, it settles threads after three days without
15activity and when their pull request merges. An eligible idle thread also settles when its pull
16request closes. An open pull request blocks inactivity settlement. Active work, pending input, and
17live background work keep the thread active. T3 Code settles from a closed or merged pull request
18only when its timestamp is not older than the user's latest activity. If that timestamp is not
19available, the inactivity rule still applies. A manual un-settle also keeps the thread active.
20Change these rules in **Settings > General** for the environment. A settings change affects future
21settlement and does not reopen a settled thread. Settings saved by older clients on one device no
22longer control this behavior.
23
1324When you un-settle a thread, it returns to the top of the active list so you can find it right
1425away. Its timestamps do not change. Other threads keep their positions.
1526
packages/client-runtime/src/state/threadSettled.test.ts deleted-587
......@@ -1,587 +0,0 @@
1import {
2 ProjectId,
3 ProviderInstanceId,
4 ThreadId,
5 TurnId,
6 type OrchestrationThreadShell,
7} from "@t3tools/contracts";
8import { describe, expect, it } from "vite-plus/test";
9
10import {
11 canSettle,
12 changeRequestAutoSettles,
13 effectiveSettled,
14 hasQueuedTurnStart,
15 threadLastActivityAt,
16 type ChangeRequestStateLike,
17} from "./threadSettled.ts";
18
19const NOW = "2026-04-10T00:00:00.000Z";
20const FRESH = "2026-04-09T00:00:00.000Z";
21const STALE = "2026-04-06T23:59:59.999Z";
22
23describe("changeRequestAutoSettles", () => {
24 it.each([
25 ["open", true, false],
26 ["merged", true, true],
27 ["merged", false, false],
28 ["closed", false, true],
29 [null, false, false],
30 ] as const)("state=%s autoSettleOnMerge=%s returns %s", (state, autoSettleOnMerge, expected) => {
31 expect(changeRequestAutoSettles(state === null ? null : { state }, { autoSettleOnMerge })).toBe(
32 expected,
33 );
34 });
35
36 const THREAD_CREATED_AT = "2026-04-01T00:00:00.000Z";
37 const idleThread = {
38 createdAt: THREAD_CREATED_AT,
39 latestUserMessageAt: null,
40 latestTurn: null,
41 };
42
43 it("ignores a terminal change request last touched before the thread existed", () => {
44 for (const state of ["merged", "closed"] as const) {
45 expect(
46 changeRequestAutoSettles(
47 { state, updatedAt: "2026-03-31T23:59:59.999Z" },
48 { thread: idleThread },
49 ),
50 ).toBe(false);
51 }
52 });
53
54 it("settles on a terminal change request touched at or after the thread's latest event", () => {
55 for (const updatedAt of [THREAD_CREATED_AT, "2026-04-02T00:00:00.000Z"]) {
56 expect(changeRequestAutoSettles({ state: "merged", updatedAt }, { thread: idleThread })).toBe(
57 true,
58 );
59 }
60 });
61
62 it("never re-settles a thread revived after the merge", () => {
63 // Settling on a merge happens once: a user message newer than the PR's
64 // last activity means the conversation outlived the PR.
65 const revived = {
66 createdAt: THREAD_CREATED_AT,
67 latestUserMessageAt: "2026-04-05T00:00:00.000Z",
68 latestTurn: null,
69 };
70 expect(
71 changeRequestAutoSettles(
72 { state: "merged", updatedAt: "2026-04-03T00:00:00.000Z" },
73 { thread: revived },
74 ),
75 ).toBe(false);
76 // A merge landing after the revival still settles.
77 expect(
78 changeRequestAutoSettles(
79 { state: "merged", updatedAt: "2026-04-06T00:00:00.000Z" },
80 { thread: revived },
81 ),
82 ).toBe(true);
83 });
84
85 it("still settles when the merge lands during an in-flight turn", () => {
86 // Anchor is user-initiated activity only: the agent finishing a turn
87 // after the merge must not block the settle the merge earned.
88 const midTurnMerge = {
89 createdAt: THREAD_CREATED_AT,
90 latestUserMessageAt: "2026-04-02T00:00:00.000Z",
91 latestTurn: {
92 turnId: TurnId.make("turn-mid"),
93 state: "completed" as const,
94 requestedAt: "2026-04-02T00:00:00.000Z",
95 startedAt: "2026-04-02T00:00:05.000Z",
96 completedAt: "2026-04-02T00:20:00.000Z",
97 assistantMessageId: null,
98 },
99 };
100 expect(
101 changeRequestAutoSettles(
102 { state: "merged", updatedAt: "2026-04-02T00:10:00.000Z" },
103 { thread: midTurnMerge },
104 ),
105 ).toBe(true);
106 });
107
108 it("falls back to settling when either timestamp is missing or malformed", () => {
109 expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(true);
110 expect(
111 changeRequestAutoSettles({ state: "merged", updatedAt: null }, { thread: idleThread }),
112 ).toBe(true);
113 expect(
114 changeRequestAutoSettles({ state: "merged", updatedAt: "2026-03-01T00:00:00.000Z" }, {}),
115 ).toBe(true);
116 expect(
117 changeRequestAutoSettles(
118 { state: "merged", updatedAt: "not-a-date" },
119 { thread: idleThread },
120 ),
121 ).toBe(true);
122 });
123});
124
125function makeShell(input: {
126 readonly settledOverride?: "settled" | "active" | null;
127 readonly activityAt: string | null;
128 readonly sessionStatus?: "starting" | "running";
129 readonly pending?: "approval" | "user-input";
130}): OrchestrationThreadShell {
131 const threadId = ThreadId.make("thread-1");
132 return {
133 id: threadId,
134 projectId: ProjectId.make("project-1"),
135 title: "Thread",
136 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
137 runtimeMode: "full-access",
138 interactionMode: "default",
139 branch: null,
140 worktreePath: null,
141 latestTurn:
142 input.activityAt === null
143 ? null
144 : {
145 turnId: TurnId.make("turn-1"),
146 state: "completed",
147 requestedAt: input.activityAt,
148 startedAt: null,
149 completedAt: null,
150 assistantMessageId: null,
151 },
152 createdAt: "2026-04-01T00:00:00.000Z",
153 updatedAt: NOW,
154 archivedAt: null,
155 settledOverride: input.settledOverride ?? null,
156 settledAt: input.settledOverride === "settled" ? NOW : null,
157 session:
158 input.sessionStatus === undefined
159 ? null
160 : {
161 threadId,
162 status: input.sessionStatus,
163 providerName: "Codex",
164 runtimeMode: "full-access",
165 activeTurnId: null,
166 lastError: null,
167 updatedAt: NOW,
168 },
169 latestUserMessageAt: null,
170 hasPendingApprovals: input.pending === "approval",
171 hasPendingUserInput: input.pending === "user-input",
172 hasActionableProposedPlan: false,
173 };
174}
175
176describe("threadLastActivityAt", () => {
177 it("returns the latest real user or turn activity and ignores thread/session updates", () => {
178 const shell = makeShell({ activityAt: null, sessionStatus: "running" });
179 const withActivity: OrchestrationThreadShell = {
180 ...shell,
181 latestUserMessageAt: "2026-04-04T00:00:00.000Z",
182 latestTurn: {
183 turnId: TurnId.make("turn-1"),
184 state: "completed",
185 requestedAt: "2026-04-03T00:00:00.000Z",
186 startedAt: "2026-04-05T00:00:00.000Z",
187 completedAt: "2026-04-06T00:00:00.000Z",
188 assistantMessageId: null,
189 },
190 };
191
192 expect(threadLastActivityAt(withActivity)).toBe("2026-04-06T00:00:00.000Z");
193 expect(threadLastActivityAt(shell)).toBeNull();
194 });
195});
196
197describe("effectiveSettled", () => {
198 const overrideCases = [null, "settled", "active"] as const;
199 const changeRequestStates = [undefined, "open", "merged"] as const;
200 const inactivityCases = [
201 ["fresh", FRESH],
202 ["stale", STALE],
203 ["no-activity", null],
204 ] as const;
205 const runningCases = [false, true] as const;
206 const pendingCases = [undefined, "approval", "user-input"] as const;
207 const truthTable = overrideCases.flatMap((settledOverride) =>
208 changeRequestStates.flatMap((changeRequestState) =>
209 inactivityCases.flatMap(([inactivity, activityAt]) =>
210 runningCases.flatMap((running) =>
211 pendingCases.map((pending) => ({
212 settledOverride,
213 changeRequestState,
214 inactivity,
215 activityAt,
216 running,
217 pending,
218 // Settled iff nothing blocks (pending work / live session) AND
219 // the override says settled, or (with no override) a merged PR
220 // or staleness auto-settles. The "active" pin suppresses both
221 // auto signals, and an open PR suppresses the inactivity path:
222 // a thread with a PR out for review is never done, however quiet.
223 expected:
224 pending === undefined &&
225 !running &&
226 (settledOverride === "settled" ||
227 (settledOverride === null &&
228 (changeRequestState === "merged" ||
229 (changeRequestState !== "open" && inactivity === "stale")))),
230 })),
231 ),
232 ),
233 ),
234 );
235
236 it.each(truthTable)(
237 "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending",
238 ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => {
239 const shell = makeShell({
240 settledOverride,
241 activityAt,
242 ...(running ? { sessionStatus: "running" as const } : {}),
243 ...(pending === undefined ? {} : { pending }),
244 });
245 const changeRequestOptions =
246 changeRequestState === undefined
247 ? {}
248 : { changeRequest: { state: changeRequestState as ChangeRequestStateLike } };
249
250 expect(
251 effectiveSettled(shell, {
252 now: NOW,
253 autoSettleAfterDays: 3,
254 ...changeRequestOptions,
255 }),
256 ).toBe(expected);
257 },
258 );
259
260 it("treats closed change requests like merged ones", () => {
261 const shell = makeShell({ activityAt: null });
262 expect(
263 effectiveSettled(shell, {
264 now: NOW,
265 autoSettleAfterDays: null,
266 changeRequest: { state: "closed" },
267 }),
268 ).toBe(true);
269 });
270
271 it("settles immediately when a change request merges or closes", () => {
272 const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });
273 for (const changeRequestState of ["merged", "closed"] as const) {
274 expect(
275 effectiveSettled(recentlyActive, {
276 now: NOW,
277 autoSettleAfterDays: null,
278 changeRequest: { state: changeRequestState },
279 }),
280 ).toBe(true);
281 }
282 });
283
284 it("ignores a change request that merged before the thread's latest event", () => {
285 // A new thread started at a worktree root inherits the branch's old
286 // merged PR, and a revived thread outlives its merge; neither settles
287 // the live conversation.
288 const fresh = makeShell({ activityAt: FRESH });
289 for (const state of ["merged", "closed"] as const) {
290 expect(
291 effectiveSettled(fresh, {
292 now: NOW,
293 autoSettleAfterDays: null,
294 changeRequest: { state, updatedAt: "2026-03-20T00:00:00.000Z" },
295 }),
296 ).toBe(false);
297 }
298 // A merge during the thread's life still settles it.
299 expect(
300 effectiveSettled(fresh, {
301 now: NOW,
302 autoSettleAfterDays: null,
303 changeRequest: { state: "merged", updatedAt: "2026-04-09T00:00:00.000Z" },
304 }),
305 ).toBe(true);
306 });
307
308 it("can keep a merged change request active", () => {
309 const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });
310 expect(
311 effectiveSettled(recentlyActive, {
312 now: NOW,
313 autoSettleAfterDays: null,
314 autoSettleOnMerge: false,
315 changeRequest: { state: "merged" },
316 }),
317 ).toBe(false);
318
319 expect(
320 effectiveSettled(recentlyActive, {
321 now: NOW,
322 autoSettleAfterDays: null,
323 autoSettleOnMerge: false,
324 changeRequest: { state: "closed" },
325 }),
326 ).toBe(true);
327 });
328
329 it("never auto-settles a stale thread with an open change request", () => {
330 const stale = makeShell({ activityAt: STALE });
331 expect(
332 effectiveSettled(stale, {
333 now: NOW,
334 autoSettleAfterDays: 3,
335 changeRequest: { state: "open" },
336 }),
337 ).toBe(false);
338 // An explicit user settle still wins: open PR only blocks the auto path.
339 const settled = makeShell({ settledOverride: "settled", activityAt: STALE });
340 expect(
341 effectiveSettled(settled, {
342 now: NOW,
343 autoSettleAfterDays: 3,
344 changeRequest: { state: "open" },
345 }),
346 ).toBe(true);
347 });
348
349 it("keeps an explicitly un-settled merged-PR thread active", () => {
350 const shell = makeShell({
351 settledOverride: "active",
352 activityAt: "2026-04-09T23:59:59.999Z",
353 });
354 expect(
355 effectiveSettled(shell, {
356 now: NOW,
357 autoSettleAfterDays: null,
358 changeRequest: { state: "merged" },
359 }),
360 ).toBe(false);
361 });
362
363 it("never settles a starting session, even with a settled override", () => {
364 const shell = makeShell({
365 settledOverride: "settled",
366 activityAt: STALE,
367 sessionStatus: "starting",
368 });
369 expect(
370 effectiveSettled(shell, {
371 now: NOW,
372 autoSettleAfterDays: 3,
373 changeRequest: { state: "merged" },
374 }),
375 ).toBe(false);
376 });
377
378 it("keeps a new turn active from queued through starting and running", () => {
379 const requestedAt = "2026-04-09T12:00:00.000Z";
380 const transitionNow = "2026-04-09T12:00:30.000Z";
381 const base = makeShell({
382 settledOverride: null,
383 activityAt: STALE,
384 });
385 const queued: OrchestrationThreadShell = {
386 ...base,
387 latestUserMessageAt: requestedAt,
388 latestTurn: null,
389 session: null,
390 };
391 const starting: OrchestrationThreadShell = {
392 ...queued,
393 session: {
394 threadId: queued.id,
395 status: "starting",
396 providerName: "Codex",
397 runtimeMode: "full-access",
398 activeTurnId: null,
399 lastError: null,
400 updatedAt: requestedAt,
401 },
402 };
403 const running: OrchestrationThreadShell = {
404 ...starting,
405 session: {
406 ...starting.session!,
407 status: "running",
408 activeTurnId: TurnId.make("turn-new"),
409 },
410 };
411
412 for (const shell of [queued, starting, running]) {
413 expect(
414 effectiveSettled(shell, {
415 now: transitionNow,
416 autoSettleAfterDays: 3,
417 changeRequest: { state: "merged" },
418 }),
419 ).toBe(false);
420 }
421 });
422
423 it("uses a strict inactivity boundary and honors a null threshold", () => {
424 const boundary = makeShell({
425 activityAt: "2026-04-07T00:00:00.000Z",
426 });
427 const stale = makeShell({ activityAt: STALE });
428
429 expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false);
430 expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false);
431 });
432});
433
434describe("hasQueuedTurnStart", () => {
435 const QUEUED_AT = "2026-04-09T12:00:00.000Z";
436 // Within the adoption grace window of the queued message.
437 const JUST_AFTER = { now: "2026-04-09T12:00:30.000Z" };
438
439 it("flags a user message no turn has picked up, within the grace window", () => {
440 const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null };
441 expect(hasQueuedTurnStart(noTurn, JUST_AFTER)).toBe(true);
442
443 const staleTurn = {
444 ...makeShell({ activityAt: FRESH }),
445 latestUserMessageAt: QUEUED_AT,
446 };
447 expect(hasQueuedTurnStart(staleTurn, JUST_AFTER)).toBe(true);
448 });
449
450 it("expires after the grace window: an unadopted message is a failed start, not queued work", () => {
451 const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null };
452 expect(hasQueuedTurnStart(noTurn, { now: "2026-04-09T12:03:00.000Z" })).toBe(false);
453 // Historical shells (e.g. from servers that never carried latestTurn)
454 // must never read as queued.
455 expect(hasQueuedTurnStart(noTurn, { now: NOW })).toBe(false);
456 });
457
458 it("clears once a turn adopts the message or the start fails", () => {
459 const adopted = {
460 ...makeShell({ activityAt: QUEUED_AT }),
461 latestUserMessageAt: QUEUED_AT,
462 };
463 expect(hasQueuedTurnStart(adopted, JUST_AFTER)).toBe(false);
464
465 const failed = makeShell({ activityAt: FRESH });
466 const failedShell = {
467 ...failed,
468 latestUserMessageAt: QUEUED_AT,
469 session: {
470 threadId: failed.id,
471 status: "error" as const,
472 providerName: "Codex",
473 runtimeMode: "full-access" as const,
474 activeTurnId: null,
475 lastError: "boom",
476 updatedAt: NOW,
477 },
478 };
479 expect(hasQueuedTurnStart(failedShell, JUST_AFTER)).toBe(false);
480 });
481
482 it("is quiet without user messages", () => {
483 expect(hasQueuedTurnStart(makeShell({ activityAt: FRESH }), JUST_AFTER)).toBe(false);
484 });
485
486 it("bounds the grace window in both directions: a future-stamped message is skew, not queued work", () => {
487 // Message timestamps originate on other devices; a clock an hour ahead
488 // must not hold the queued state for the whole skew.
489 const skewed = {
490 latestUserMessageAt: "2026-04-09T13:00:00.000Z",
491 latestTurn: null,
492 session: null,
493 };
494 expect(hasQueuedTurnStart(skewed, { now: "2026-04-09T12:00:00.000Z" })).toBe(false);
495 // A small negative age (within the grace window) still reads as queued.
496 const slightlyAhead = {
497 latestUserMessageAt: "2026-04-09T12:00:30.000Z",
498 latestTurn: null,
499 session: null,
500 };
501 expect(hasQueuedTurnStart(slightlyAhead, { now: "2026-04-09T12:00:00.000Z" })).toBe(true);
502 });
503});
504
505describe("canSettle", () => {
506 it("blocks every state effectiveSettled refuses to classify as settled", () => {
507 expect(canSettle(makeShell({ activityAt: FRESH }), { now: NOW })).toBe(true);
508 expect(
509 canSettle(makeShell({ activityAt: FRESH, sessionStatus: "starting" }), { now: NOW }),
510 ).toBe(false);
511 expect(
512 canSettle(makeShell({ activityAt: FRESH, sessionStatus: "running" }), { now: NOW }),
513 ).toBe(false);
514 expect(canSettle(makeShell({ activityAt: FRESH, pending: "approval" }), { now: NOW })).toBe(
515 false,
516 );
517 expect(canSettle(makeShell({ activityAt: FRESH, pending: "user-input" }), { now: NOW })).toBe(
518 false,
519 );
520 });
521
522 it("blocks settling a queued turn start, only within the grace window", () => {
523 const queued = {
524 ...makeShell({ activityAt: FRESH }),
525 latestUserMessageAt: "2026-04-09T12:00:00.000Z",
526 };
527 const justAfter = "2026-04-09T12:00:30.000Z";
528 expect(canSettle(queued, { now: justAfter })).toBe(false);
529 // effectiveSettled must agree: queued work never auto-settles either,
530 // even with a merged PR.
531 expect(
532 effectiveSettled(queued, {
533 now: justAfter,
534 autoSettleAfterDays: 3,
535 changeRequest: { state: "merged" },
536 }),
537 ).toBe(false);
538 // Past the window the message is a failed/stale start: settleable again.
539 expect(canSettle(queued, { now: NOW })).toBe(true);
540 });
541
542 it("lets a server-accepted settle overrule the clock-derived queued blocker", () => {
543 // The settle action ran with wall-clock `now` (past the grace window);
544 // the list partition re-evaluates with a minute-floored `now` that is
545 // still INSIDE the window. settledAt >= message time proves the server
546 // already adjudicated this exact message, so the row must not snap back
547 // to active until the coarser clock catches up.
548 const messageAt = "2026-04-09T12:00:00.000Z";
549 const flooredNow = "2026-04-09T12:01:00.000Z";
550 const base = makeShell({ settledOverride: "settled", activityAt: null });
551 const settledAfterMessage = {
552 ...base,
553 latestUserMessageAt: messageAt,
554 settledAt: "2026-04-09T12:02:10.000Z",
555 };
556 expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true);
557 expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe(
558 true,
559 );
560
561 // A message NEWER than settledAt is genuinely new work: still blocked
562 // until the server's auto-unsettle lands.
563 const messageAfterSettle = {
564 ...base,
565 latestUserMessageAt: "2026-04-09T12:03:00.000Z",
566 settledAt: "2026-04-09T12:02:10.000Z",
567 };
568 expect(
569 effectiveSettled(messageAfterSettle, {
570 now: "2026-04-09T12:03:30.000Z",
571 autoSettleAfterDays: 3,
572 }),
573 ).toBe(false);
574 });
575
576 it("agrees with effectiveSettled's blockers for explicitly settled shells", () => {
577 // Anything canSettle rejects must render as active even when the user
578 // settled it earlier.
579 const blocked = makeShell({
580 settledOverride: "settled",
581 activityAt: FRESH,
582 pending: "user-input",
583 });
584 expect(canSettle(blocked, { now: NOW })).toBe(false);
585 expect(effectiveSettled(blocked, { now: NOW, autoSettleAfterDays: 3 })).toBe(false);
586 });
587});
packages/client-runtime/src/state/threadSettled.ts+2-191
......@@ -1,100 +1,6 @@
11// @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels.
22import type { OrchestrationThreadShell } from "@t3tools/contracts";
33
4export type ChangeRequestStateLike = "open" | "closed" | "merged";
5
6/**
7 * The slice of a change request the settle rules need. `updatedAt` is the
8 * provider's last-activity timestamp; for a merged/closed request it bounds
9 * when the terminal state landed.
10 */
11export interface ChangeRequestSettleSource {
12 readonly state: ChangeRequestStateLike;
13 readonly updatedAt?: string | null | undefined;
14}
15
16/** What the settle rules need to know about the thread's own timeline. */
17export type ThreadActivitySource = Pick<
18 OrchestrationThreadShell,
19 "createdAt" | "latestUserMessageAt" | "latestTurn"
20>;
21
22/**
23 * Latest USER-initiated activity: messages and the turn requests they start,
24 * deliberately not the agent-side started/completed stamps. The settle-on-
25 * merge anchor uses this so a merge landing mid-turn still settles the
26 * thread when that turn finishes, while a user re-engaging after the merge
27 * blocks it for good. Falls back to creation time for untouched threads.
28 */
29function threadUserActivityAnchorAt(thread: ThreadActivitySource): string {
30 const messageAt = thread.latestUserMessageAt;
31 const requestedAt = thread.latestTurn?.requestedAt;
32 let anchor = thread.createdAt;
33 for (const candidate of [messageAt, requestedAt]) {
34 if (candidate != null && Date.parse(candidate) > Date.parse(anchor)) {
35 anchor = candidate;
36 }
37 }
38 return anchor;
39}
40
41/**
42 * Returns whether the change request settles the thread immediately. A
43 * terminal request settles the thread only while it postdates every user-
44 * initiated event in it: settling on a merge happens ONCE. A request last
45 * touched before the thread was created is inherited branch history (a new
46 * thread started at a worktree root whose PR already merged), and one older
47 * than the user's latest engagement was already adjudicated — re-engaging a
48 * thread whose PR merged is the user saying the conversation outlived the
49 * PR. Unknown timestamps keep the old always-settle behavior.
50 */
51export function changeRequestAutoSettles(
52 changeRequest: ChangeRequestSettleSource | null | undefined,
53 options: {
54 readonly autoSettleOnMerge?: boolean | undefined;
55 readonly thread?: ThreadActivitySource | null | undefined;
56 } = {},
57): boolean {
58 if (changeRequest == null) return false;
59 const terminal =
60 changeRequest.state === "closed" ||
61 (changeRequest.state === "merged" && options.autoSettleOnMerge !== false);
62 if (!terminal) return false;
63 if (changeRequest.updatedAt == null || options.thread == null) return true;
64 const updatedAtMs = Date.parse(changeRequest.updatedAt);
65 const anchorAtMs = Date.parse(threadUserActivityAnchorAt(options.thread));
66 // Malformed timestamps fall back to settling, matching servers that never
67 // report updatedAt.
68 if (Number.isNaN(updatedAtMs) || Number.isNaN(anchorAtMs)) return true;
69 return updatedAtMs >= anchorAtMs;
70}
71
72const DAY_MS = 24 * 60 * 60 * 1_000;
73
74export function threadLastActivityAt(
75 shell: Pick<OrchestrationThreadShell, "latestUserMessageAt" | "latestTurn">,
76): string | null {
77 const candidates = [
78 shell.latestUserMessageAt,
79 shell.latestTurn?.requestedAt,
80 shell.latestTurn?.startedAt,
81 shell.latestTurn?.completedAt,
82 ];
83 let latest: string | null = null;
84 let latestTimestamp = Number.NEGATIVE_INFINITY;
85
86 for (const candidate of candidates) {
87 if (candidate === null || candidate === undefined) continue;
88 const timestamp = Date.parse(candidate);
89 if (timestamp > latestTimestamp) {
90 latest = candidate;
91 latestTimestamp = timestamp;
92 }
93 }
94
95 return latest;
96}
97
984/**
995 * A queued turn start lives for at most this long: session adoption takes
1006 * seconds, so a user message still unadopted after the grace window is a
......@@ -103,6 +9,7 @@ export function threadLastActivityAt(
1039 * such threads would be permanently unsettleable.
10410 */
10511export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;
12const DAY_MS = 24 * 60 * 60 * 1_000;
10613
10714/**
10815 * A user message no turn has picked up yet: the turn.start command was
......@@ -137,28 +44,6 @@ export function hasQueuedTurnStart(
13744 );
13845}
13946
140/**
141 * A thread may be settled only when none of effectiveSettled's activity
142 * blockers hold. This is deliberately the same list: anything the partition
143 * refuses to CLASSIFY as settled must also be refused as a settle TARGET.
144 * The server enforces its own invariants; this client-side twin exists so
145 * the UI can disable/reject before a round trip.
146 */
147export function canSettle(
148 shell: Pick<
149 OrchestrationThreadShell,
150 "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestUserMessageAt" | "latestTurn"
151 >,
152 options: { readonly now: string },
153): boolean {
154 if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false;
155 if (shell.session?.status === "starting" || shell.session?.status === "running") return false;
156 // Queued work is as blocked-on-progress as a live session: settling it
157 // (or auto-settling it on a closed PR) would hide a just-requested turn.
158 if (hasQueuedTurnStart(shell, options)) return false;
159 return true;
160}
161
16247/**
16348 * The snooze lifecycle fields plus everything needed to detect a raised
16449 * hand. Snooze is an overlay on the active state: a snoozed thread stays
......@@ -181,8 +66,7 @@ export type ThreadSnoozeShell = Pick<
18166 * the session failed, or a run completed after the snooze was set — the
18267 * v1 taste of event-based snooze ("something happened" wakes early).
18368 * 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
185 * effectiveSettled.
69 * the thread from classifying as snoozed.
18670 */
18771export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean {
18872 if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true;
......@@ -283,79 +167,6 @@ export function threadWokeAt(
283167 return wakeAtMs <= Date.parse(options.now) ? shell.snoozedUntil : null;
284168}
285169
286/**
287 * Settled resolution over the server-backed settled lifecycle. Activity
288 * blockers (pending approval/user-input, a live session, an unadjudicated
289 * queued turn) are checked first and hold a thread active regardless of any
290 * override. Past the blockers, the explicit user override (thread.settle /
291 * thread.unsettle commands, projected into settledOverride + settledAt)
292 * wins in both directions; without one, a thread can auto-settle on a
293 * merged PR or always on a closed PR (both only while the terminal state is
294 * the thread's latest event, see changeRequestAutoSettles), or settles on
295 * inactivity past the window.
296 * An open PR blocks the inactivity path entirely. The server
297 * un-settles on real activity (user message, session start, approval/
298 * user-input request), so an override never goes stale silently.
299 */
300export function effectiveSettled(
301 shell: OrchestrationThreadShell,
302 options: {
303 readonly now: string;
304 readonly autoSettleAfterDays: number | null;
305 readonly autoSettleOnMerge?: boolean;
306 readonly changeRequest?: ChangeRequestSettleSource | null;
307 },
308): boolean {
309 // Blocked work must remain visible even when a user explicitly settled it.
310 if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false;
311 if (shell.session?.status === "starting" || shell.session?.status === "running") return false;
312 if (hasQueuedTurnStart(shell, { now: options.now })) {
313 // The queued-turn blocker alone is forgivable: it is clock-derived, and
314 // list callers pass a coarser `now` than the settle action used. When
315 // the server already adjudicated the queued message by accepting a
316 // settle after it (settledAt stamps server accept time), trust that
317 // ruling — otherwise a settle near the grace boundary leaves the row
318 // pinned active until the caller's clock ticks over. A message NEWER
319 // than settledAt is genuinely new work and keeps the block until the
320 // server's auto-unsettle lands.
321 const serverAdjudicated =
322 shell.settledOverride === "settled" &&
323 shell.settledAt !== null &&
324 shell.latestUserMessageAt !== null &&
325 Date.parse(shell.settledAt) >= Date.parse(shell.latestUserMessageAt);
326 if (!serverAdjudicated) return false;
327 }
328 if (shell.settledOverride === "settled") return true;
329 // "active" is the explicit keep-active pin: it suppresses auto-settle
330 // until real activity clears it server-side.
331 if (shell.settledOverride === "active") return false;
332 if (
333 changeRequestAutoSettles(options.changeRequest, {
334 autoSettleOnMerge: options.autoSettleOnMerge,
335 thread: shell,
336 })
337 ) {
338 return true;
339 }
340 // An open PR is unfinished business regardless of how long the thread has
341 // been quiet: review can take days, and hiding the thread would bury the
342 // work waiting on it. A configured merge, a close, or an explicit user
343 // settle resolves it.
344 if (options.changeRequest?.state === "open") return false;
345 if (options.autoSettleAfterDays === null) return false;
346
347 const lastActivityAt = threadLastActivityAt(shell);
348 if (lastActivityAt === null) return false;
349
350 // threadLastActivityAt only returns candidates whose Date.parse beat
351 // -Infinity, so this parse is a real number; a malformed `now` yields NaN,
352 // the comparison is false, and the thread stays active (never a surprise
353 // auto-settle on bad input).
354 return (
355 Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS
356 );
357}
358
359170const HOUR_MS = 60 * 60 * 1_000;
360171const EVENING_HOUR = 18;
361172const MORNING_HOUR = 9;
packages/client-runtime/src/state/threadSnoozed.test.ts+60
......@@ -6,12 +6,14 @@ import { describe, expect, it } from "vite-plus/test";
66import {
77 canSnooze,
88 effectiveSnoozed,
9 hasQueuedTurnStart,
910 resolveSnoozePresets,
1011 snoozeWakeLabel,
1112 threadRaisedHandWhileSnoozed,
1213 threadWokeAt,
1314 type ThreadSnoozeShell,
1415} from "./threadSettled.ts";
16import type { OrchestrationThreadShell } from "@t3tools/contracts";
1517
1618const NOW = "2026-04-10T12:00:00.000Z";
1719const SNOOZED_AT = "2026-04-10T09:00:00.000Z";
......@@ -61,6 +63,15 @@ function makeShell(input: {
6163 };
6264}
6365
66type QueuedTurnShell = Pick<
67 OrchestrationThreadShell,
68 "latestUserMessageAt" | "latestTurn" | "session"
69>;
70
71function makeQueuedTurnShell(overrides: Partial<QueuedTurnShell> = {}): QueuedTurnShell {
72 return { latestUserMessageAt: null, latestTurn: null, session: null, ...overrides };
73}
74
6475describe("effectiveSnoozed", () => {
6576 it("hides a thread whose wake time is in the future", () => {
6677 expect(effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE }), { now: NOW })).toBe(true);
......@@ -202,6 +213,55 @@ describe("canSnooze", () => {
202213 });
203214});
204215
216describe("hasQueuedTurnStart", () => {
217 it("expires queued state after two minutes", () => {
218 const thread = makeQueuedTurnShell({
219 latestUserMessageAt: "2026-04-10T11:57:59.000Z",
220 });
221 expect(hasQueuedTurnStart(thread, { now: NOW })).toBe(false);
222 });
223
224 it("clears queued state when a turn adopts the message or the session fails", () => {
225 const messageAt = "2026-04-10T11:59:00.000Z";
226 const adopted = makeQueuedTurnShell({
227 latestUserMessageAt: messageAt,
228 latestTurn: {
229 turnId: TurnId.make("turn-adopted"),
230 state: "running",
231 requestedAt: messageAt,
232 startedAt: null,
233 completedAt: null,
234 assistantMessageId: null,
235 },
236 });
237 const failed = makeQueuedTurnShell({
238 latestUserMessageAt: messageAt,
239 session: {
240 threadId: ThreadId.make("thread-failed"),
241 status: "error",
242 providerName: "Codex",
243 runtimeMode: "full-access",
244 activeTurnId: null,
245 lastError: "failed",
246 updatedAt: NOW,
247 },
248 });
249 expect(hasQueuedTurnStart(adopted, { now: NOW })).toBe(false);
250 expect(hasQueuedTurnStart(failed, { now: NOW })).toBe(false);
251 });
252
253 it("bounds future client clock skew", () => {
254 const farAhead = makeQueuedTurnShell({
255 latestUserMessageAt: "2026-04-10T12:03:00.000Z",
256 });
257 const slightlyAhead = makeQueuedTurnShell({
258 latestUserMessageAt: "2026-04-10T12:01:00.000Z",
259 });
260 expect(hasQueuedTurnStart(farAhead, { now: NOW })).toBe(false);
261 expect(hasQueuedTurnStart(slightlyAhead, { now: NOW })).toBe(true);
262 });
263});
264
205265describe("threadWokeAt", () => {
206266 it("is null for never-snoozed and still-snoozed threads", () => {
207267 expect(threadWokeAt(makeShell({}), { now: NOW })).toBe(null);
packages/contracts/src/environment.ts+2
......@@ -63,6 +63,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
6363 pre-settlement servers, so clients treat missing as unsupported and
6464 never send the commands under version skew. */
6565 threadSettlement: Schema.optionalKey(Schema.Boolean),
66 /** Server evaluates merge and inactivity settlement without a client. */
67 threadAutoSettlement: Schema.optionalKey(Schema.Boolean),
6668 /** Server understands thread.snooze / thread.unsnooze commands. Same
6769 version-skew contract as threadSettlement. */
6870 threadSnooze: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/orchestration.ts+8
......@@ -772,6 +772,13 @@ const ThreadSettleCommand = Schema.Struct({
772772 threadId: ThreadId,
773773});
774774
775const ThreadAutoSettleCommand = Schema.Struct({
776 type: Schema.Literal("thread.auto-settle"),
777 commandId: CommandId,
778 threadId: ThreadId,
779 snapshotSequence: NonNegativeInt,
780});
781
775782const ThreadUnsettleCommand = Schema.Struct({
776783 type: Schema.Literal("thread.unsettle"),
777784 commandId: CommandId,
......@@ -1107,6 +1114,7 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({
11071114});
11081115
11091116const InternalOrchestrationCommand = Schema.Union([
1117 ThreadAutoSettleCommand,
11101118 ThreadSessionSetCommand,
11111119 ThreadMessageAssistantDeltaCommand,
11121120 ThreadMessageAssistantCompleteCommand,
packages/contracts/src/settings.test.ts+22-17
......@@ -137,11 +137,8 @@ describe("ClientSettings environment identification", () => {
137137});
138138
139139describe("ClientSettings sidebar", () => {
140 it("defaults to the current sidebar with automatic merge and inactivity settling", () => {
141 const settings = decodeClientSettings({});
142 expect(settings.legacySidebarEnabled).toBe(false);
143 expect(settings.sidebarAutoSettleAfterDays).toBe(3);
144 expect(settings.sidebarAutoSettleOnMerge).toBe(true);
140 it("defaults to the current sidebar", () => {
141 expect(decodeClientSettings({}).legacySidebarEnabled).toBe(false);
145142 });
146143
147144 it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => {
......@@ -166,25 +163,33 @@ describe("ClientSettings sidebar", () => {
166163 expect(decodeClientSettingsPatch({ confirmThreadUnpin: true }).confirmThreadUnpin).toBe(true);
167164 expect(() => decodeClientSettingsPatch({ confirmThreadUnpin: "yes" })).toThrow();
168165 });
166});
169167
170 it("allows auto-settle by inactivity to be disabled", () => {
171 expect(
172 decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays,
173 ).toBeNull();
168describe("ServerSettings thread settlement", () => {
169 it("defaults merge settlement on and inactivity settlement to three days", () => {
170 const settings = decodeServerSettings({});
171 expect(settings.sidebarAutoSettleAfterDays).toBe(3);
172 expect(settings.sidebarAutoSettleOnMerge).toBe(true);
174173 });
175174
176 it("allows auto-settle on merge to be disabled", () => {
177 expect(decodeClientSettings({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge).toBe(
178 false,
179 );
175 it("allows both automatic rules to be disabled", () => {
180176 expect(
181 decodeClientSettingsPatch({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge,
182 ).toBe(false);
177 decodeServerSettings({
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 });
183188 });
184189
185190 it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => {
186 expect(() => decodeClientSettings({ sidebarAutoSettleAfterDays: value })).toThrow();
187 expect(() => decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow();
191 expect(() => decodeServerSettings({ sidebarAutoSettleAfterDays: value })).toThrow();
192 expect(() => decodeServerSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow();
188193 });
189194});
190195
packages/contracts/src/settings.ts+6-6
......@@ -253,10 +253,6 @@ export const ClientSettingsSchema = Schema.Struct({
253253 // old keys, so everyone, including prior beta opt-outs, resets to the new
254254 // default sidebar.
255255 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))),
260256 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
261257 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
262258 ),
......@@ -658,6 +654,10 @@ export const ServerSettings = Schema.Struct({
658654 * between a desktop window and a phone attached to the same server.
659655 */
660656 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))),
661661 backgroundActivity: BackgroundActivitySettings,
662662 // Legacy flat fields retained for old settings files and old clients. New
663663 // consumers should resolve `backgroundActivity` instead.
......@@ -883,6 +883,8 @@ export const ServerSettingsPatch = Schema.Struct({
883883 enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean),
884884 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
885885 enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),
886 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
887 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
886888 backgroundActivity: Schema.optionalKey(
887889 Schema.Struct({
888890 schemaVersion: Schema.optionalKey(Schema.Literal(1)),
......@@ -976,8 +978,6 @@ export const ClientSettingsPatch = Schema.Struct({
976978 planModeEnabled: Schema.optionalKey(Schema.Boolean),
977979 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
978980 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
979 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
980 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
981981 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
982982 sidebarProjectGroupingOverrides: Schema.optionalKey(
983983 Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),