authorclover caruso <git@paperclover.net> 2026-10-13 16:10:14-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
loge967bf6d83336edcf6c8bf6ea1e22edb45f32a16
treee838b45ddc69813a9b2b43c2331bde5a0f47171d
parentb3a01d86bc6c4c62f7469c43b02aa53b97fe3f44
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

feat(at-work): manage workspaces and the settled chat lifecycle

Clover wants to settle finished work without deleting its workspace immediately or leaving unused dev servers running. Create managed workspaces from private host configuration through the sidebar and command palette, preserving permissions, drafts, and navigation. Manual Settle closes every chat-owned terminal and browser tab. Warn only when running programs or Browser state will be lost; empty chats and idle shells settle directly. Unknown resource state still warns. Other chats sharing the folder keep their resources. Undo restores the chat, not its commands or page state. Bulk settlement confirms once; cancellation preserves selection. Automatic settlement skips open resources, including a guarded compensation for resources opened while settlement commits. Archive quiet settled chats Saturday at 9 AM in server-local time, catching up after downtime. Keep managed workspaces until archival and release of other references. Archive settled work chats once all linked PRs are verified merged, including settlement after a merge. A new current-head CI failure returns a settled chat to Active and adds a Mergebot notice without starting an agent; settling again acknowledges that failure, while failed reruns can wake it again. Use verified live Mergebot data for work PR decisions. Disconnection does not certify a merge or failure; Saturday archival and inactivity rules remain independent. Guard lifecycle mutations against newer activity or manual intent. Reconcile unused dev frontends at startup, on lifecycle events, and during sweeps, preserving active peers and pending work. Keep Archive all in the Settled header context menu and offer Archive for selected chats. Both include hidden selections and group Undo over actual successes. Guard each settled archive against its current snapshot, skip raced state changes, and continue with later eligible chats. Show the full hidden count and expand it in one action. Skip revived chats and preserve navigation when the reader moves during archival. Use the central inbox grant for offline organization in the settled-chat lifecycle. Skip source resource queries while that machine is disconnected and explain that cleanup waits for reconnection; keep deletion and execution tied to source grants. Assisted-by: gpt-6.1-sol

59 files changed, 5514 insertions(+), 393 deletions(-)

apps/mobile/src/features/home/useThreadListActions.test.ts+208-28
...@@ -11,19 +11,55 @@ import * as Cause from "effect/Cause";...@@ -11,19 +11,55 @@ import * as Cause from "effect/Cause";
11import { AsyncResult } from "effect/reactivity";11import { AsyncResult } from "effect/reactivity";
12import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";12import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
1313
14const resourceQueries = vi.hoisted(() => ({
15 calls: 0,
16 terminals: null as { threadId: string; hasRunningSubprocess: boolean }[] | null,
17 tabs: null as unknown[] | null,
18}));
19vi.mock("@t3tools/client-runtime/state/runtime", async (original) => ({
20 ...(await original<typeof import("@t3tools/client-runtime/state/runtime")>()),
21 executeAtomQuery: async (_registry: unknown, atom: string) => {
22 resourceQueries.calls++;
23 const value =
24 atom === "terminal-metadata" ? resourceQueries.terminals : { sessions: resourceQueries.tabs };
25 return (atom === "terminal-metadata" ? resourceQueries.terminals : resourceQueries.tabs) ===
26 null
27 ? { _tag: "Failure", cause: {} }
28 : { _tag: "Success", value };
29 },
30}));
31vi.mock("../../connection/hub", () => ({
32 hubConnection: { organizationPermissionAtom: (id: string) => `organization:${id}` },
33}));
34vi.mock("../../connection/catalog", () => ({
35 environmentCatalog: { stateAtom: (id: string) => `connection:${id}` },
36}));
37vi.mock("../../state/terminal", () => ({
38 terminalEnvironment: { metadataSnapshot: () => "terminal-metadata" },
39}));
40vi.mock("../../state/preview", () => ({ previewEnvironment: { list: () => "preview-list" } }));
41
14const state = vi.hoisted(() => ({42const state = vi.hoisted(() => ({
15 pendingOrder: null as object | null,43 pendingOrder: null as object | null,
16 dropBusy: false,44 dropBusy: false,
17 scopes: new Map<string, Set<string>>(),45 scopes: new Map<string, Set<string>>(),
46 organization: new Set<string>(),
18 shells: [] as EnvironmentThreadShell[],47 shells: [] as EnvironmentThreadShell[],
19 requests: [] as {48 requests: [] as {
20 action: string;49 action: string;
21 environmentId: string;50 environmentId: string;
22 input: { threadId: string; orderKey?: string };51 input: { threadId: string; orderKey?: string };
23 }[],52 }[],
24 dialogs: [] as { onConfirm: () => void }[],53 autoConfirm: true,
54 dialogs: [] as {
55 title: string;
56 message?: string;
57 onConfirm: () => void;
58 onCancel?: () => void;
59 }[],
25 alerts: [] as {60 alerts: [] as {
26 title: string;61 title: string;
62 message?: string;
27 buttons?: { text: string; onPress?: () => void }[];63 buttons?: { text: string; onPress?: () => void }[];
28 }[],64 }[],
29 afterRequest: undefined as (() => void) | undefined,65 afterRequest: undefined as (() => void) | undefined,
...@@ -35,8 +71,12 @@ vi.mock("react", () => ({...@@ -35,8 +71,12 @@ vi.mock("react", () => ({
35}));71}));
36vi.mock("react-native", () => ({72vi.mock("react-native", () => ({
37 Alert: {73 Alert: {
38 alert: (title: string, _message: string, buttons?: { text: string; onPress?: () => void }[]) =>74 alert: (title: string, message: string, buttons?: { text: string; onPress?: () => void }[]) => {
39 state.alerts.push({ title, buttons }),75 state.alerts.push({ title, message, buttons });
76 if (state.autoConfirm && title.startsWith("Settle ")) {
77 buttons?.find((button) => button.text === "Settle")?.onPress?.();
78 }
79 },
40 },80 },
41}));81}));
42vi.mock("expo-haptics", () => ({82vi.mock("expo-haptics", () => ({
...@@ -44,17 +84,29 @@ vi.mock("expo-haptics", () => ({...@@ -44,17 +84,29 @@ vi.mock("expo-haptics", () => ({
44 ImpactFeedbackStyle: { Light: "light" },84 ImpactFeedbackStyle: { Light: "light" },
45}));85}));
46vi.mock("../../components/ConfirmDialogHost", () => ({86vi.mock("../../components/ConfirmDialogHost", () => ({
47 showConfirmDialog: (dialog: { onConfirm: () => void }) => state.dialogs.push(dialog),87 showConfirmDialog: (dialog: {
88 title: string;
89 message?: string;
90 onConfirm: () => void;
91 onCancel?: () => void;
92 }) => {
93 state.dialogs.push(dialog);
94 if (state.autoConfirm && dialog.title.startsWith("Settle ")) dialog.onConfirm();
95 },
48}));96}));
49vi.mock("../archive/useArchivedThreadSnapshots", () => ({97vi.mock("../archive/useArchivedThreadSnapshots", () => ({
50 refreshArchivedThreadsForEnvironment: () => {},98 refreshArchivedThreadsForEnvironment: () => {},
51}));99}));
52vi.mock("../../state/session", () => ({100vi.mock("../../state/session", () => ({
101 readThreadOrganizationPermission: (environmentId: string) =>
102 state.organization.has(environmentId) ||
103 state.scopes.get(environmentId)?.has("orchestration:operate") === true,
53 readEnvironmentScope: (environmentId: string, scope: string) =>104 readEnvironmentScope: (environmentId: string, scope: string) =>
54 state.scopes.get(environmentId)?.has(scope) === true,105 state.scopes.get(environmentId)?.has(scope) === true,
55}));106}));
56vi.mock("../../state/server", () => ({107vi.mock("../../state/server", () => ({
57 environmentServerConfigsAtom: "server-configs",108 environmentCapabilitiesAtom: "environment-capabilities",
109 threadListEnvironmentsAtom: "thread-list-environments",
58}));110}));
59vi.mock("../../state/atom-registry", () => ({111vi.mock("../../state/atom-registry", () => ({
60 appAtomRegistry: {112 appAtomRegistry: {
...@@ -62,28 +114,35 @@ vi.mock("../../state/atom-registry", () => ({...@@ -62,28 +114,35 @@ vi.mock("../../state/atom-registry", () => ({
62 state.dropBusy = value;114 state.dropBusy = value;
63 },115 },
64 get: (atom: string) =>116 get: (atom: string) =>
65 atom === "thread-drop-busy"117 atom.startsWith("organization:")
66 ? state.dropBusy118 ? state.organization.has(atom.slice(13))
67 : atom === "thread-shells"119 : atom.startsWith("connection:")
68 ? state.shells120 ? AsyncResult.success({ phase: "offline" })
69 : atom === "queued-thread-keys"121 : atom === "thread-drop-busy"
70 ? new Set<string>()122 ? state.dropBusy
71 : new Map(123 : atom === "thread-shells"
72 [...state.scopes.keys()].map((environmentId) => [124 ? state.shells
73 environmentId,125 : atom === "queued-thread-keys"
74 {126 ? new Set<string>()
75 environment: {127 : atom === "thread-list-environments"
76 capabilities: {128 ? {
77 threadSettlement: true,129 settlementEnvironmentIds: new Set(state.scopes.keys()),
78 threadSnooze: true,130 snoozeEnvironmentIds: new Set(state.scopes.keys()),
79 threadPinning: true,131 }
80 threadPinReorder: true,132 : new Map(
81 threadTitleRegeneration: true,133 [...state.scopes.keys()].map((environmentId) => [
82 },134 environmentId,
83 },135 {
84 },136 threadSettlement: true,
85 ]),137 threadSnooze: true,
86 ),138 threadPinning: true,
139 threadActiveReorder: true,
140 threadPinReorder: true,
141 threadTitleRegeneration: true,
142 threadAutoSettleOptOut: true,
143 },
144 ]),
145 ),
87 },146 },
88}));147}));
89vi.mock("../../state/use-atom-command", () => ({148vi.mock("../../state/use-atom-command", () => ({
...@@ -131,7 +190,22 @@ vi.mock("../../state/threads", () => ({...@@ -131,7 +190,22 @@ vi.mock("../../state/threads", () => ({
131 input: { threadId: string; orderKey?: string };190 input: { threadId: string; orderKey?: string };
132 }) => {191 }) => {
133 state.requests.push({ action, ...request });192 state.requests.push({ action, ...request });
134 if (!state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope)) {193 if (
194 !state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope) &&
195 !(
196 state.organization.has(request.environmentId) &&
197 [
198 "settle",
199 "unsettle",
200 "snooze",
201 "unsnooze",
202 "pin",
203 "unpin",
204 "reorderPin",
205 "setAutoSettle",
206 ].includes(action)
207 )
208 ) {
135 return AsyncResult.failure(Cause.fail(new Error("Thread operation denied")));209 return AsyncResult.failure(Cause.fail(new Error("Thread operation denied")));
136 }210 }
137 state.afterRequest?.();211 state.afterRequest?.();
...@@ -169,6 +243,10 @@ const mutationCases = [...@@ -169,6 +243,10 @@ const mutationCases = [
169] as const;243] as const;
170244
171beforeEach(() => {245beforeEach(() => {
246 resourceQueries.calls = 0;
247 state.organization.clear();
248 resourceQueries.terminals = null;
249 resourceQueries.tabs = null;
172 state.pendingOrder = null;250 state.pendingOrder = null;
173 state.dropBusy = false;251 state.dropBusy = false;
174 state.scopes = new Map([252 state.scopes = new Map([
...@@ -177,6 +255,7 @@ beforeEach(() => {...@@ -177,6 +255,7 @@ beforeEach(() => {
177 ]);255 ]);
178 state.requests = [];256 state.requests = [];
179 state.shells = [];257 state.shells = [];
258 state.autoConfirm = true;
180 state.dialogs = [];259 state.dialogs = [];
181 state.alerts = [];260 state.alerts = [];
182 state.afterRequest = undefined;261 state.afterRequest = undefined;
...@@ -184,6 +263,89 @@ beforeEach(() => {...@@ -184,6 +263,89 @@ beforeEach(() => {
184263
185afterEach(() => vi.unstubAllEnvs());264afterEach(() => vi.unstubAllEnvs());
186265
266describe("settle resource warning", () => {
267 it.each([
268 { terminals: [] },
269 { terminals: [{ threadId: "thread", hasRunningSubprocess: false }] },
270 ])("settles empty or idle-shell chats without prompting", async ({ terminals }) => {
271 resourceQueries.terminals = terminals;
272 resourceQueries.tabs = [];
273 await expect(useThreadListActions().settleThread(makeThread())).resolves.toBe(true);
274 expect(state.dialogs).toHaveLength(0);
275 expect(state.alerts).toHaveLength(0);
276 expect(state.requests.map((request) => request.action)).toEqual(["settle"]);
277 });
278
279 it.each([
280 {
281 terminals: [{ threadId: "thread", hasRunningSubprocess: true }],
282 tabs: [],
283 text: "Undo cannot restart them.",
284 },
285 { terminals: [], tabs: [{}], text: "Undo cannot restore page state." },
286 { terminals: [], tabs: null, text: "could not be checked" },
287 ])(
288 "warns for programs, browser tabs, or unavailable resource data: $text",
289 async ({ terminals, tabs, text }) => {
290 resourceQueries.terminals = terminals;
291 resourceQueries.tabs = tabs;
292 await useThreadListActions().settleThread(makeThread());
293 expect(state.dialogs[0]?.message).toContain(text);
294 expect(state.requests.map((request) => request.action)).toEqual(["settle"]);
295 },
296 );
297
298 it.each(["ios", "android"])("cancels %s settlement before dismissal or mutation", async (os) => {
299 vi.stubEnv("EXPO_OS", os);
300 state.autoConfirm = false;
301 const actions = useThreadListActions();
302 const pending = actions.settleThread(makeThread());
303 await vi.waitFor(() => expect(os === "ios" ? state.alerts : state.dialogs).toHaveLength(1));
304 const warning = os === "ios" ? state.alerts[0] : state.dialogs[0];
305 expect(warning?.message).toContain("stops any running commands");
306 expect(warning?.message).toContain("Undo cannot restore commands or page state");
307 expect(state.requests).toEqual([]);
308
309 if (os === "ios")
310 state.alerts[0]?.buttons?.find((button) => button.text === "Cancel")?.onPress?.();
311 else state.dialogs[0]?.onCancel?.();
312
313 await expect(pending).resolves.toBe(false);
314 expect(state.requests).toEqual([]);
315 });
316
317 it.each(["ios", "android"])("rechecks permission after %s confirmation", async (os) => {
318 vi.stubEnv("EXPO_OS", os);
319 state.autoConfirm = false;
320 const pending = useThreadListActions().settleThread(makeThread());
321 await vi.waitFor(() => expect(os === "ios" ? state.alerts : state.dialogs).toHaveLength(1));
322 state.scopes.get(primaryEnvironmentId)!.clear();
323 if (os === "ios")
324 state.alerts[0]?.buttons?.find((button) => button.text === "Settle")?.onPress?.();
325 else state.dialogs[0]?.onConfirm();
326
327 await expect(pending).resolves.toBe(false);
328 expect(state.requests).toEqual([]);
329 });
330
331 it("warns for a move to Settled before issuing its command", async () => {
332 state.autoConfirm = false;
333 const thread = makeThread();
334 state.shells = [thread];
335 const pending = useThreadListActions().moveThread(thread, {
336 section: "settled",
337 targetId: null,
338 placement: "after",
339 });
340 expect(state.requests).toEqual([]);
341 await vi.waitFor(() => expect(state.dialogs).toHaveLength(1));
342 state.dialogs[0]?.onConfirm();
343
344 await expect(pending).resolves.toBe(true);
345 expect(state.requests.map((request) => request.action)).toEqual(["settle"]);
346 });
347});
348
187describe("thread list operation permissions", () => {349describe("thread list operation permissions", () => {
188 it.each(mutationCases)(350 it.each(mutationCases)(
189 "%s rejects a retained callback after its environment loses permission",351 "%s rejects a retained callback after its environment loses permission",
...@@ -324,3 +486,21 @@ describe("pinned thread operation permissions", () => {...@@ -324,3 +486,21 @@ describe("pinned thread operation permissions", () => {
324 expect(state.requests).toHaveLength(1);486 expect(state.requests).toHaveLength(1);
325 });487 });
326});488});
489
490it("organizes a chat through the central grant while source mutations remain denied", async () => {
491 resourceQueries.terminals = [];
492 resourceQueries.tabs = [];
493 state.organization.add(otherEnvironmentId);
494 const thread = makeThread({ environmentId: otherEnvironmentId });
495 const actions = useThreadListActions();
496 expect(await actions.settleThread(thread)).toBe(true);
497 expect(resourceQueries.calls).toBe(0);
498 expect(await actions.unsettleThread(thread)).toBe(true);
499 expect(await actions.snoozeThread(thread, "2027-01-01T00:00:00.000Z")).toBe(true);
500 expect(await actions.regenerateThreadTitle(thread)).toBe(false);
501 actions.confirmDeleteThread(thread);
502 expect(state.requests.map((request) => request.action)).toEqual(["settle", "unsettle", "snooze"]);
503 state.organization.clear();
504 expect(await actions.pinThread(thread)).toBe(false);
505 expect(state.requests).toHaveLength(3);
506});
apps/mobile/src/features/home/useThreadListActions.ts+137-41
...@@ -1,6 +1,14 @@...@@ -1,6 +1,14 @@
1import * as Option from "effect/Option";
2import { AsyncResult } from "effect/reactivity";
3import { environmentCatalog } from "../../connection/catalog";
4import { hubConnection } from "../../connection/hub";
1import type { ThreadMoveDestination } from "../threads/threadOrder";5import type { ThreadMoveDestination } from "../threads/threadOrder";
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";6import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";7import {
8 canSnooze,
9 effectiveSnoozed,
10 resolveThreadSettlementWarning,
11} from "@t3tools/client-runtime/state/thread-settled";
4import { AuthOrchestrationOperateScope } from "@t3tools/contracts";12import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
5import * as Cause from "effect/Cause";13import * as Cause from "effect/Cause";
6import * as Haptics from "expo-haptics";14import * as Haptics from "expo-haptics";
...@@ -13,8 +21,11 @@ import { scopedThreadKey } from "../../lib/scopedEntities";...@@ -13,8 +21,11 @@ import { scopedThreadKey } from "../../lib/scopedEntities";
13import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";21import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
14import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";22import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";
15import { appAtomRegistry } from "../../state/atom-registry";23import { appAtomRegistry } from "../../state/atom-registry";
16import { environmentServerConfigsAtom } from "../../state/server";24import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
17import { readEnvironmentScope } from "../../state/session";25import { terminalEnvironment } from "../../state/terminal";
26import { previewEnvironment } from "../../state/preview";
27import { environmentCapabilitiesAtom, threadListEnvironmentsAtom } from "../../state/server";
28import { readEnvironmentScope, readThreadOrganizationPermission } from "../../state/session";
18import { environmentThreadShells, threadEnvironment } from "../../state/threads";29import { environmentThreadShells, threadEnvironment } from "../../state/threads";
19import { queuedThreadKeysAtom } from "../../state/use-thread-outbox";30import { queuedThreadKeysAtom } from "../../state/use-thread-outbox";
20import { useAtomCommand } from "../../state/use-atom-command";31import { useAtomCommand } from "../../state/use-atom-command";
...@@ -36,29 +47,23 @@ import { resolveThreadTitleRename } from "../threads/thread-title-rename";...@@ -36,29 +47,23 @@ import { resolveThreadTitleRename } from "../threads/thread-title-rename";
36 (capability defaults false on decode for older servers). */47 (capability defaults false on decode for older servers). */
37function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["environmentId"]) {48function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["environmentId"]) {
38 return (49 return (
39 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities50 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadSettlement === true
40 .threadSettlement === true
41 );51 );
42}52}
4353
44function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["environmentId"]) {54function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["environmentId"]) {
45 return (55 return appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadSnooze === true;
46 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
47 .threadSnooze === true
48 );
49}56}
5057
51function environmentSupportsPinning(environmentId: EnvironmentThreadShell["environmentId"]) {58function environmentSupportsPinning(environmentId: EnvironmentThreadShell["environmentId"]) {
52 return (59 return (
53 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities60 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadPinning === true
54 .threadPinning === true
55 );61 );
56}62}
5763
58function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["environmentId"]) {64function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["environmentId"]) {
59 return (65 return (
60 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities66 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadPinReorder === true
61 .threadPinReorder === true
62 );67 );
63}68}
6469
...@@ -66,8 +71,8 @@ function environmentSupportsAutoSettleOptOut(...@@ -66,8 +71,8 @@ function environmentSupportsAutoSettleOptOut(
66 environmentId: EnvironmentThreadShell["environmentId"],71 environmentId: EnvironmentThreadShell["environmentId"],
67) {72) {
68 return (73 return (
69 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities74 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadAutoSettleOptOut ===
70 .threadAutoSettleOptOut === true75 true
71 );76 );
72}77}
7378
...@@ -75,8 +80,8 @@ function environmentSupportsTitleRegeneration(...@@ -75,8 +80,8 @@ function environmentSupportsTitleRegeneration(
75 environmentId: EnvironmentThreadShell["environmentId"],80 environmentId: EnvironmentThreadShell["environmentId"],
76) {81) {
77 return (82 return (
78 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities83 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadTitleRegeneration ===
79 .threadTitleRegeneration === true84 true
80 );85 );
81}86}
8287
...@@ -110,8 +115,17 @@ function actionFailureTitle(action: ThreadListAction): string {...@@ -110,8 +115,17 @@ function actionFailureTitle(action: ThreadListAction): string {
110 return "Could not delete thread";115 return "Could not delete thread";
111}116}
112117
113function checkThreadOperationPermission(thread: EnvironmentThreadShell, title: string): boolean {118function checkThreadOperationPermission(
114 if (readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return true;119 thread: EnvironmentThreadShell,
120 title: string,
121 organization = false,
122): boolean {
123 if (
124 organization
125 ? readThreadOrganizationPermission(thread.environmentId)
126 : readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)
127 )
128 return true;
115 Alert.alert(title, "This connection cannot change threads.");129 Alert.alert(title, "This connection cannot change threads.");
116 return false;130 return false;
117}131}
...@@ -129,14 +143,20 @@ function useThreadActionExecutor(...@@ -129,14 +143,20 @@ function useThreadActionExecutor(
129143
130 const executeAction = useCallback(144 const executeAction = useCallback(
131 async (action: ThreadListAction, thread: EnvironmentThreadShell) => {145 async (action: ThreadListAction, thread: EnvironmentThreadShell) => {
132 if (!checkThreadOperationPermission(thread, actionFailureTitle(action))) return false;146 if (
147 !checkThreadOperationPermission(
148 thread,
149 actionFailureTitle(action),
150 action === "settle" || action === "unsettle",
151 )
152 )
153 return false;
133 const key = scopedThreadKey(thread.environmentId, thread.id);154 const key = scopedThreadKey(thread.environmentId, thread.id);
134 if (inFlightThreadKeys.current.has(key)) {155 if (inFlightThreadKeys.current.has(key)) {
135 return false;156 return false;
136 }157 }
137158
138 inFlightThreadKeys.current.add(key);159 inFlightThreadKeys.current.add(key);
139 selectionHaptic();
140 try {160 try {
141 if (161 if (
142 (action === "settle" || action === "unsettle") &&162 (action === "settle" || action === "unsettle") &&
...@@ -148,6 +168,85 @@ function useThreadActionExecutor(...@@ -148,6 +168,85 @@ function useThreadActionExecutor(
148 );168 );
149 return false;169 return false;
150 }170 }
171 if (action === "settle") {
172 if (thread.settledOverride === "settled") return false;
173 const queryOptions = {
174 refresh: true,
175 reportFailure: false,
176 signal: AbortSignal.timeout(3_000),
177 };
178 const sourceOffline =
179 appAtomRegistry.get(hubConnection.organizationPermissionAtom(thread.environmentId)) &&
180 Option.getOrNull(
181 AsyncResult.value(
182 appAtomRegistry.get(environmentCatalog.stateAtom(thread.environmentId)),
183 ),
184 )?.phase !== "connected";
185 const [terminals, previews] = sourceOffline
186 ? [null, null]
187 : await Promise.all([
188 executeAtomQuery(
189 appAtomRegistry,
190 terminalEnvironment.metadataSnapshot({
191 environmentId: thread.environmentId,
192 input: null,
193 }),
194 queryOptions,
195 ),
196 executeAtomQuery(
197 appAtomRegistry,
198 previewEnvironment.list({
199 environmentId: thread.environmentId,
200 input: { threadId: thread.id },
201 }),
202 queryOptions,
203 ),
204 ]);
205 const resourceWarning = resolveThreadSettlementWarning({
206 runningCommands:
207 terminals?._tag === "Success"
208 ? terminals.value.some(
209 (terminal) => terminal.threadId === thread.id && terminal.hasRunningSubprocess,
210 )
211 : null,
212 browserTabs: previews?._tag === "Success" ? previews.value.sessions.length > 0 : null,
213 });
214 const warning =
215 resourceWarning !== null && sourceOffline
216 ? `${resourceWarning} Cleanup waits until the computer reconnects.`
217 : resourceWarning;
218 const confirmed =
219 warning === null ||
220 (await new Promise<boolean>((resolve) => {
221 const title = `Settle "${thread.title}"?`;
222 if (process.env.EXPO_OS === "ios") {
223 Alert.alert(title, warning, [
224 { text: "Cancel", style: "cancel", onPress: () => resolve(false) },
225 { text: "Settle", style: "destructive", onPress: () => resolve(true) },
226 ]);
227 } else {
228 showConfirmDialog({
229 title,
230 message: warning,
231 confirmText: "Settle",
232 destructive: true,
233 onConfirm: () => resolve(true),
234 onCancel: () => resolve(false),
235 });
236 }
237 }));
238 if (
239 !confirmed ||
240 !checkThreadOperationPermission(
241 thread,
242 actionFailureTitle(action),
243 action === "settle" || action === "unsettle",
244 )
245 ) {
246 return false;
247 }
248 }
249 selectionHaptic();
151 // Archive keeps its original, narrower guard: never interrupt a250 // Archive keeps its original, narrower guard: never interrupt a
152 // thread mid-turn.251 // thread mid-turn.
153 if (action === "archive" && !threadCanArchive(thread.runtime)) {252 if (action === "archive" && !threadCanArchive(thread.runtime)) {
...@@ -291,7 +390,7 @@ export function useThreadListActions(): {...@@ -291,7 +390,7 @@ export function useThreadListActions(): {
291 );390 );
292 const snoozeThread = useCallback(391 const snoozeThread = useCallback(
293 async (thread: EnvironmentThreadShell, snoozedUntil: string) => {392 async (thread: EnvironmentThreadShell, snoozedUntil: string) => {
294 if (!checkThreadOperationPermission(thread, "Could not snooze thread")) return false;393 if (!checkThreadOperationPermission(thread, "Could not snooze thread", true)) return false;
295 const key = scopedThreadKey(thread.environmentId, thread.id);394 const key = scopedThreadKey(thread.environmentId, thread.id);
296 if (snoozeInFlightThreadKeys.current.has(key)) {395 if (snoozeInFlightThreadKeys.current.has(key)) {
297 return false;396 return false;
...@@ -349,7 +448,7 @@ export function useThreadListActions(): {...@@ -349,7 +448,7 @@ export function useThreadListActions(): {
349 );448 );
350 const unsnoozeThread = useCallback(449 const unsnoozeThread = useCallback(
351 async (thread: EnvironmentThreadShell) => {450 async (thread: EnvironmentThreadShell) => {
352 if (!checkThreadOperationPermission(thread, "Could not wake thread")) return false;451 if (!checkThreadOperationPermission(thread, "Could not wake thread", true)) return false;
353 const key = scopedThreadKey(thread.environmentId, thread.id);452 const key = scopedThreadKey(thread.environmentId, thread.id);
354 if (snoozeInFlightThreadKeys.current.has(key)) {453 if (snoozeInFlightThreadKeys.current.has(key)) {
355 return false;454 return false;
...@@ -397,7 +496,7 @@ export function useThreadListActions(): {...@@ -397,7 +496,7 @@ export function useThreadListActions(): {
397 );496 );
398 const pinThread = useCallback(497 const pinThread = useCallback(
399 async (thread: EnvironmentThreadShell) => {498 async (thread: EnvironmentThreadShell) => {
400 if (!checkThreadOperationPermission(thread, "Could not pin thread")) return false;499 if (!checkThreadOperationPermission(thread, "Could not pin thread", true)) return false;
401 if (!environmentSupportsPinning(thread.environmentId)) {500 if (!environmentSupportsPinning(thread.environmentId)) {
402 Alert.alert(501 Alert.alert(
403 "Could not pin thread",502 "Could not pin thread",
...@@ -438,7 +537,7 @@ export function useThreadListActions(): {...@@ -438,7 +537,7 @@ export function useThreadListActions(): {
438 );537 );
439 const unpinThread = useCallback(538 const unpinThread = useCallback(
440 async (thread: EnvironmentThreadShell) => {539 async (thread: EnvironmentThreadShell) => {
441 if (!checkThreadOperationPermission(thread, "Could not unpin thread")) return false;540 if (!checkThreadOperationPermission(thread, "Could not unpin thread", true)) return false;
442 if (!environmentSupportsPinning(thread.environmentId)) {541 if (!environmentSupportsPinning(thread.environmentId)) {
443 Alert.alert(542 Alert.alert(
444 "Could not unpin thread",543 "Could not unpin thread",
...@@ -467,6 +566,8 @@ export function useThreadListActions(): {...@@ -467,6 +566,8 @@ export function useThreadListActions(): {
467 );566 );
468 const setThreadAutoSettle = useCallback(567 const setThreadAutoSettle = useCallback(
469 async (thread: EnvironmentThreadShell, enabled: boolean) => {568 async (thread: EnvironmentThreadShell, enabled: boolean) => {
569 if (!checkThreadOperationPermission(thread, "Could not update auto-settle", true))
570 return false;
470 if (!environmentSupportsAutoSettleOptOut(thread.environmentId)) {571 if (!environmentSupportsAutoSettleOptOut(thread.environmentId)) {
471 Alert.alert(572 Alert.alert(
472 "Could not update auto-settle",573 "Could not update auto-settle",
...@@ -589,7 +690,7 @@ export function useThreadListActions(): {...@@ -589,7 +690,7 @@ export function useThreadListActions(): {
589 });690 });
590 const moveThread = useCallback(691 const moveThread = useCallback(
591 async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => {692 async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => {
592 if (!checkThreadOperationPermission(thread, "Could not move thread")) return false;693 if (!checkThreadOperationPermission(thread, "Could not move thread", true)) return false;
593 if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false;694 if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false;
594 const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);695 const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
595 const current = shells.find(696 const current = shells.find(
...@@ -612,9 +713,10 @@ export function useThreadListActions(): {...@@ -612,9 +713,10 @@ export function useThreadListActions(): {
612 appAtomRegistry.set(threadDropBusyAtom, false);713 appAtomRegistry.set(threadDropBusyAtom, false);
613 }714 }
614 }715 }
615 const configs = appAtomRegistry.get(environmentServerConfigsAtom);716 const capabilitiesByEnvironment = appAtomRegistry.get(environmentCapabilitiesAtom);
717 const listEnvironments = appAtomRegistry.get(threadListEnvironmentsAtom);
616 const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => {718 const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => {
617 const capabilities = configs.get(environmentId)?.environment.capabilities;719 const capabilities = capabilitiesByEnvironment.get(environmentId);
618 return section === "pinned"720 return section === "pinned"
619 ? capabilities?.threadPinReorder === true721 ? capabilities?.threadPinReorder === true
620 : capabilities?.threadActiveReorder === true;722 : capabilities?.threadActiveReorder === true;
...@@ -631,22 +733,16 @@ export function useThreadListActions(): {...@@ -631,22 +733,16 @@ export function useThreadListActions(): {
631 section,733 section,
632 now: new Date().toISOString(),734 now: new Date().toISOString(),
633 queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom),735 queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom),
634 settlementEnvironmentIds: new Set(736 settlementEnvironmentIds: listEnvironments.settlementEnvironmentIds,
635 [...configs].flatMap(([id, config]) =>737 snoozeEnvironmentIds: listEnvironments.snoozeEnvironmentIds,
636 config.environment.capabilities.threadSettlement === true ? [id] : [],
637 ),
638 ),
639 snoozeEnvironmentIds: new Set(
640 [...configs].flatMap(([id, config]) =>
641 config.environment.capabilities.threadSnooze === true ? [id] : [],
642 ),
643 ),
644 });738 });
645 const assignments = createThreadMovePlanner({739 const assignments = createThreadMovePlanner({
646 allThreads: shells,740 allThreads: shells,
647 ordered,741 ordered,
648 section,742 section,
649 reorderableEnvironmentIds: new Set([...configs.keys()].filter(supportsReorder)),743 reorderableEnvironmentIds: new Set(
744 [...capabilitiesByEnvironment.keys()].filter(supportsReorder),
745 ),
650 })(scopedThreadKey(thread.environmentId, thread.id), direction);746 })(scopedThreadKey(thread.environmentId, thread.id), direction);
651 if (assignments === null) return false;747 if (assignments === null) return false;
652 const lifecycle = threadDropLifecycle(thread, section, new Date().toISOString());748 const lifecycle = threadDropLifecycle(thread, section, new Date().toISOString());
...@@ -668,7 +764,7 @@ export function useThreadListActions(): {...@@ -668,7 +764,7 @@ export function useThreadListActions(): {
668 );764 );
669 for (const assignment of assignments) {765 for (const assignment of assignments) {
670 const target = shellByKey.get(assignment.id);766 const target = shellByKey.get(assignment.id);
671 if (target && !checkThreadOperationPermission(target, "Could not move thread"))767 if (target && !checkThreadOperationPermission(target, "Could not move thread", true))
672 return false;768 return false;
673 }769 }
674 selectionHaptic();770 selectionHaptic();
...@@ -717,7 +813,7 @@ export function useThreadListActions(): {...@@ -717,7 +813,7 @@ export function useThreadListActions(): {
717 if (pending !== null && !pending.isPending()) return false;813 if (pending !== null && !pending.isPending()) return false;
718 const target = shellByKey.get(assignment.id);814 const target = shellByKey.get(assignment.id);
719 if (target === undefined) continue;815 if (target === undefined) continue;
720 if (!checkThreadOperationPermission(target, "Could not move thread")) return false;816 if (!checkThreadOperationPermission(target, "Could not move thread", true)) return false;
721 const result = await reorder({817 const result = await reorder({
722 environmentId: target.environmentId,818 environmentId: target.environmentId,
723 input: { threadId: target.id, orderKey: assignment.orderKey },819 input: { threadId: target.id, orderKey: assignment.orderKey },
apps/mobile/src/features/threads/ThreadFeed.tsx+5-5
...@@ -1011,7 +1011,7 @@ function renderFeedEntry(...@@ -1011,7 +1011,7 @@ function renderFeedEntry(
1011 readonly markdownLinkHandlers: MarkdownLinkHandlers;1011 readonly markdownLinkHandlers: MarkdownLinkHandlers;
1012 readonly renderMarkdownImage: MarkdownImageRenderer;1012 readonly renderMarkdownImage: MarkdownImageRenderer;
1013 readonly renderViewedImage: MarkdownImageRenderer;1013 readonly renderViewedImage: MarkdownImageRenderer;
1014 readonly renderReasoning: (text: string) => ReactNode;1014 readonly renderMarkdown: (text: string) => ReactNode;
1015 readonly iconSubtleColor: string | import("react-native").ColorValue;1015 readonly iconSubtleColor: string | import("react-native").ColorValue;
1016 readonly screenColor: string;1016 readonly screenColor: string;
1017 readonly userBubbleColor: string | import("react-native").ColorValue;1017 readonly userBubbleColor: string | import("react-native").ColorValue;
...@@ -1481,7 +1481,7 @@ function renderFeedEntry(...@@ -1481,7 +1481,7 @@ function renderFeedEntry(
1481 onCopyRow={props.onCopyWorkRow}1481 onCopyRow={props.onCopyWorkRow}
1482 onToggleRow={props.onToggleWorkRow}1482 onToggleRow={props.onToggleWorkRow}
1483 renderImage={props.renderViewedImage}1483 renderImage={props.renderViewedImage}
1484 renderReasoning={props.renderReasoning}1484 renderMarkdown={props.renderMarkdown}
1485 onPressPreview={props.onPressPreview}1485 onPressPreview={props.onPressPreview}
1486 />1486 />
1487 );1487 );
...@@ -2013,7 +2013,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2013,7 +2013,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2013 [props.environmentId, props.threadId, props.workspaceRoot],2013 [props.environmentId, props.threadId, props.workspaceRoot],
2014 );2014 );
2015 const markdownStyles = useMarkdownStyles();2015 const markdownStyles = useMarkdownStyles();
2016 const renderReasoning = useCallback(2016 const renderMarkdown = useCallback(
2017 (text: string) => (2017 (text: string) => (
2018 <AssistantMarkdownContent2018 <AssistantMarkdownContent
2019 markdown={text}2019 markdown={text}
...@@ -2537,7 +2537,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2537,7 +2537,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2537 markdownLinkHandlers,2537 markdownLinkHandlers,
2538 renderMarkdownImage,2538 renderMarkdownImage,
2539 renderViewedImage,2539 renderViewedImage,
2540 renderReasoning,2540 renderMarkdown,
2541 iconSubtleColor,2541 iconSubtleColor,
2542 screenColor,2542 screenColor,
2543 userBubbleColor,2543 userBubbleColor,
...@@ -2603,7 +2603,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2603,7 +2603,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2603 props.workspaceRoot,2603 props.workspaceRoot,
2604 renderMarkdownImage,2604 renderMarkdownImage,
2605 renderViewedImage,2605 renderViewedImage,
2606 renderReasoning,2606 renderMarkdown,
2607 ],2607 ],
2608 );2608 );
26092609
apps/mobile/src/features/threads/thread-work-log.tsx+33-5
...@@ -487,7 +487,7 @@ interface ThreadWorkLogProps {...@@ -487,7 +487,7 @@ interface ThreadWorkLogProps {
487 readonly onCopyRow: (rowId: string, value: string) => void;487 readonly onCopyRow: (rowId: string, value: string) => void;
488 readonly onToggleRow: (rowId: string, anchorKey: string) => void;488 readonly onToggleRow: (rowId: string, anchorKey: string) => void;
489 readonly renderImage: MarkdownImageRenderer;489 readonly renderImage: MarkdownImageRenderer;
490 readonly renderReasoning: (text: string) => ReactNode;490 readonly renderMarkdown: (text: string) => ReactNode;
491 readonly onPressPreview: (source: FilePreviewSource) => void;491 readonly onPressPreview: (source: FilePreviewSource) => void;
492}492}
493493
...@@ -505,7 +505,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {...@@ -505,7 +505,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {
505 onCopyRow={props.onCopyRow}505 onCopyRow={props.onCopyRow}
506 onToggleRow={props.onToggleRow}506 onToggleRow={props.onToggleRow}
507 renderImage={props.renderImage}507 renderImage={props.renderImage}
508 renderReasoning={props.renderReasoning}508 renderMarkdown={props.renderMarkdown}
509 onPressPreview={props.onPressPreview}509 onPressPreview={props.onPressPreview}
510 themeAppearance={props.themeAppearance}510 themeAppearance={props.themeAppearance}
511 />511 />
...@@ -519,7 +519,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {...@@ -519,7 +519,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {
519 props.onCopyRow,519 props.onCopyRow,
520 props.onToggleRow,520 props.onToggleRow,
521 props.renderImage,521 props.renderImage,
522 props.renderReasoning,522 props.renderMarkdown,
523 props.onPressPreview,523 props.onPressPreview,
524 props.themeAppearance,524 props.themeAppearance,
525 ],525 ],
...@@ -540,7 +540,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {...@@ -540,7 +540,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {
540 return (540 return (
541 <ScrollView nestedScrollEnabled className="ml-7 max-h-96 py-1">541 <ScrollView nestedScrollEnabled className="ml-7 max-h-96 py-1">
542 {props.activities.map((row) => (542 {props.activities.map((row) => (
543 <View key={row.id}>{props.renderReasoning(row.workEntry.detail ?? "")}</View>543 <View key={row.id}>{props.renderMarkdown(row.workEntry.detail ?? "")}</View>
544 ))}544 ))}
545 </ScrollView>545 </ScrollView>
546 );546 );
...@@ -907,6 +907,34 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(...@@ -907,6 +907,34 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
907 : null,907 : null,
908 );908 );
909 const failureItem = row.projectedItem.item;909 const failureItem = row.projectedItem.item;
910 if (failureItem.type === "notification" && failureItem.source.kind === "system") {
911 const failed = failureItem.outcome === "failed";
912 return (
913 <View accessibilityLiveRegion="polite" className="py-1">
914 <WorkLogPressable onLongPress={() => props.onCopyRow(row.id, row.getCopyText())}>
915 <WorkLogIconSlot>
916 <WorkLogIcon
917 icon={failed ? "exclamationmark.circle" : "t3-code"}
918 color={props.iconSubtleColor}
919 colorClassName={failed ? "accent-danger-foreground" : undefined}
920 />
921 </WorkLogIconSlot>
922 <Text
923 selectable
924 className={cn(
925 "min-w-0 flex-1 font-t3-medium text-sm text-foreground-muted",
926 failed && "text-adaptive-rose-600-400",
927 )}
928 >
929 {failureItem.summary}
930 </Text>
931 </WorkLogPressable>
932 {failureItem.detail ? (
933 <View className="ml-7 pt-1">{props.renderMarkdown(failureItem.detail)}</View>
934 ) : null}
935 </View>
936 );
937 }
910 if (failureItem.type === "error" && failureItem.status === "failed") {938 if (failureItem.type === "error" && failureItem.status === "failed") {
911 const warning = failureItem.failure.class === "usage_limit";939 const warning = failureItem.failure.class === "usage_limit";
912 const timestamp = new Date(row.createdAt);940 const timestamp = new Date(row.createdAt);
...@@ -1224,7 +1252,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(...@@ -1224,7 +1252,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
1224 contentContainerStyle={{ paddingRight: 8 }}1252 contentContainerStyle={{ paddingRight: 8 }}
1225 >1253 >
1226 {reasoning ? (1254 {reasoning ? (
1227 props.renderReasoning(reasoning.text)1255 props.renderMarkdown(reasoning.text)
1228 ) : call ? (1256 ) : call ? (
1229 [1257 [
1230 call.command,1258 call.command,
apps/mobile/src/lib/threadActivity.test.ts+32-2
...@@ -85,7 +85,7 @@ it("shows only the structured path in expanded mobile read details", () => {...@@ -85,7 +85,7 @@ it("shows only the structured path in expanded mobile read details", () => {
85 expect(withoutPath?.canExpand).toBe(false);85 expect(withoutPath?.canExpand).toBe(false);
86});86});
8787
88it("expands a T3 Code notice to the prompt the agent got", () => {88it("keeps system notices prominent for inline Markdown details", () => {
89 const item = {89 const item = {
90 ...base("restart-notice", "2026-06-20T00:00:03.000Z", 2),90 ...base("restart-notice", "2026-06-20T00:00:03.000Z", 2),
91 type: "notification",91 type: "notification",
...@@ -99,10 +99,40 @@ it("expands a T3 Code notice to the prompt the agent got", () => {...@@ -99,10 +99,40 @@ it("expands a T3 Code notice to the prompt the agent got", () => {
99 );99 );
100100
101 expect(activity?.summary).toBe("T3 Code restarted and resumed this turn");101 expect(activity?.summary).toBe("T3 Code restarted and resumed this turn");
102 expect(activity?.canExpand).toBe(true);102 expect(activity?.prominent).toBe(true);
103 expect(activity?.canExpand).toBe(false);
103 expect(activity?.getFullDetail()).toBe("Continue where you left off.");104 expect(activity?.getFullDetail()).toBe("Continue where you left off.");
104});105});
105106
107it("keeps a runless CI failure and its Mergebot link visible outside completed work", () => {
108 const item: OrchestrationV2TurnItem = {
109 ...base("ci-failure", "2026-06-20T00:00:03.000Z", 2),
110 runId: null,
111 type: "notification",
112 source: { kind: "system" },
113 outcome: "failed",
114 summary: "CI failed for PR #42",
115 detail: "[Open PR #42 in Mergebot](https://example.com/queue/pr/42)",
116 };
117 const feed = buildThreadFeed([projected(command(), 0), projected(item, 1)]);
118 const presented = deriveThreadFeedPresentation(
119 feed,
120 {
121 runId,
122 status: "completed",
123 startedAt: "2026-06-20T00:00:01.000Z",
124 completedAt: "2026-06-20T00:00:02.000Z",
125 },
126 new Set(),
127 );
128 const activity = presented
129 .flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []))
130 .find((entry) => entry.projectedItem.item.id === item.id);
131 expect(activity?.prominent).toBe(true);
132 expect(activity?.status).toBe("failure");
133 expect(activity?.getFullDetail()).toBe(item.detail);
134});
135
106it("labels file searches with the adapter title and its search target", () => {136it("labels file searches with the adapter title and its search target", () => {
107 const item: OrchestrationV2TurnItem = {137 const item: OrchestrationV2TurnItem = {
108 ...base("file-search", "2026-06-20T00:00:03.000Z", 2),138 ...base("file-search", "2026-06-20T00:00:03.000Z", 2),
apps/mobile/src/lib/threadActivity.ts+2
...@@ -460,6 +460,7 @@ function itemIsProminent(item: OrchestrationV2TurnItem): boolean {...@@ -460,6 +460,7 @@ function itemIsProminent(item: OrchestrationV2TurnItem): boolean {
460 item.type === "fork" ||460 item.type === "fork" ||
461 item.type === "thread_created" ||461 item.type === "thread_created" ||
462 item.type === "system_notice" ||462 item.type === "system_notice" ||
463 (item.type === "notification" && item.source.kind === "system") ||
463 // An answerable card: it must stand alone and never fold away with the run.464 // An answerable card: it must stand alone and never fold away with the run.
464 item.type === "secret_request"465 item.type === "secret_request"
465 );466 );
...@@ -841,6 +842,7 @@ function toFeedActivity(...@@ -841,6 +842,7 @@ function toFeedActivity(
841 detail,842 detail,
842 canExpand:843 canExpand:
843 !(item.type === "error" && item.status === "failed") &&844 !(item.type === "error" && item.status === "failed") &&
845 !(item.type === "notification" && item.source.kind === "system") &&
844 (readPaths846 (readPaths
845 ? readPaths.length > 0 || turnItemNeedsDetailFetch(item)847 ? readPaths.length > 0 || turnItemNeedsDetailFetch(item)
846 : turnItemHasDetail(item) || workEntry.questionAnswer !== undefined),848 : turnItemHasDetail(item) || workEntry.questionAnswer !== undefined),
apps/server/src/auth/RpcAuthorization.ts+1
...@@ -46,6 +46,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -46,6 +46,7 @@ export const RPC_REQUIRED_SCOPES = {
46 ...CLIENT_GUARDED_RPC_SCOPES,46 ...CLIENT_GUARDED_RPC_SCOPES,
47 [WS_METHODS.uiSettingsGet]: AuthOrchestrationReadScope,47 [WS_METHODS.uiSettingsGet]: AuthOrchestrationReadScope,
48 [WS_METHODS.uiSettingsSubscribe]: AuthOrchestrationReadScope,48 [WS_METHODS.uiSettingsSubscribe]: AuthOrchestrationReadScope,
49 [WS_METHODS.managedWorkspacesStatus]: AuthFilesystemReadScope,
49 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,50 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,
50 [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,51 [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,
51 [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope,52 [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope,
apps/server/src/mcp/toolkits/thread/tools.ts+1-1
...@@ -30,7 +30,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts";...@@ -30,7 +30,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts";
3030
31const ThreadOrganizeTool = Tool.make("t3_thread_organize", {31const ThreadOrganizeTool = Tool.make("t3_thread_organize", {
32 description:32 description:
33 "Pin, snooze, settle, archive, or mark a thread unread. Omit threadId for this thread. snooze requires snoozedUntil. Existing thread lifecycle rules apply. Settling this thread takes effect when your turn completes, returning settlesWhenTurnEnds=true; a turn that fails or is interrupted, or a queued message, leaves it active.",33 "Pin, snooze, settle, archive, or mark a thread unread. Omit threadId for this thread. snooze requires snoozedUntil. Settling closes this chat's terminals and browser tabs, stops their running commands and media, and preserves conversation history. Unsettling cannot restore running processes or page state; tell the user before settling work with open resources. Settling this thread takes effect when your turn completes, returning settlesWhenTurnEnds=true; a turn that fails or is interrupted, or a queued message, leaves it active.",
34 parameters: Schema.Struct({34 parameters: Schema.Struct({
35 threadId: Schema.optional(ThreadId),35 threadId: Schema.optional(ThreadId),
36 action: Schema.Literals([36 action: Schema.Literals([
apps/server/src/observability/RpcInstrumentation.ts+3
...@@ -142,6 +142,9 @@ const RPC_AGGREGATES = {...@@ -142,6 +142,9 @@ const RPC_AGGREGATES = {
142 [WS_METHODS.subscribeProjectClones]: "source-control",142 [WS_METHODS.subscribeProjectClones]: "source-control",
143 [WS_METHODS.projectsListEntries]: "workspace",143 [WS_METHODS.projectsListEntries]: "workspace",
144 [WS_METHODS.devServerStatus]: "workspace",144 [WS_METHODS.devServerStatus]: "workspace",
145 [WS_METHODS.managedWorkspacesStatus]: "workspace",
146 [WS_METHODS.managedWorkspacesCreate]: "workspace",
147 [WS_METHODS.managedWorkspacesArchiveCompleted]: "workspace",
145 [WS_METHODS.devServerStart]: "workspace",148 [WS_METHODS.devServerStart]: "workspace",
146 [WS_METHODS.projectsWatch]: "workspace",149 [WS_METHODS.projectsWatch]: "workspace",
147 [WS_METHODS.projectsReadFile]: "workspace",150 [WS_METHODS.projectsReadFile]: "workspace",
apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts+273
...@@ -23,6 +23,7 @@ import * as Effect from "effect/Effect";...@@ -23,6 +23,7 @@ import * as Effect from "effect/Effect";
23import * as Layer from "effect/Layer";23import * as Layer from "effect/Layer";
24import * as SqlClient from "effect/sql/SqlClient";24import * as SqlClient from "effect/sql/SqlClient";
25import * as SqlitePersistence from "../persistence/Sqlite.ts";25import * as SqlitePersistence from "../persistence/Sqlite.ts";
26import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts";
26import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";27import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
27import * as DelegatedTaskQuestionReactor from "./DelegatedTaskQuestionReactor.ts";28import * as DelegatedTaskQuestionReactor from "./DelegatedTaskQuestionReactor.ts";
28import * as EventSink from "./EventSink.ts";29import * as EventSink from "./EventSink.ts";
...@@ -44,6 +45,7 @@ const adapter = {...@@ -44,6 +45,7 @@ const adapter = {
44const layerDatabase = SqlitePersistence.layerMemory;45const layerDatabase = SqlitePersistence.layerMemory;
45const layerTest = Layer.mergeAll(46const layerTest = Layer.mergeAll(
46 layerDatabase,47 layerDatabase,
48 OrchestrationEventStore.layer.pipe(Layer.provide(layerDatabase)),
47 ProjectionStore.layer.pipe(Layer.provide(layerDatabase)),49 ProjectionStore.layer.pipe(Layer.provide(layerDatabase)),
48 ProviderReplayHarness.layerWithRegistry(50 ProviderReplayHarness.layerWithRegistry(
49 { name: "control-reads" },51 { name: "control-reads" },
...@@ -52,6 +54,68 @@ const layerTest = Layer.mergeAll(...@@ -52,6 +54,68 @@ const layerTest = Layer.mergeAll(
52 ),54 ),
53);55);
5456
57it.effect("cancels only the automatic settlement that observed newly opened resources", () =>
58 Effect.gen(function* () {
59 const orchestrator = yield* Orchestrator.OrchestratorV2;
60 const projections = yield* ProjectionStore.ProjectionStoreV2;
61 for (const action of ["none", "manual-settle", "revive"] as const) {
62 const threadId = ThreadId.make(`thread:auto-settle-resources:${action}`);
63 yield* orchestrator.dispatch({
64 type: "thread.create",
65 commandId: CommandId.make(`create-auto-settle-resources:${action}`),
66 threadId,
67 projectId: ProjectId.make("project:auto-settle-resources"),
68 title: "Resource settlement race",
69 modelSelection,
70 runtimeMode: "full-access",
71 interactionMode: "default",
72 branch: null,
73 worktreePath: null,
74 createdBy: "user",
75 creationSource: "web",
76 });
77 const before = yield* projections.getThread(threadId);
78 const automatic = yield* orchestrator.dispatch({
79 type: "thread.auto-settle",
80 commandId: CommandId.make(`server:auto-settle:${threadId}:1`),
81 threadId,
82 snapshotAt: before.updatedAt,
83 });
84 const settledAt = (yield* projections.getThread(threadId)).settledAt;
85 if (action !== "none") {
86 yield* orchestrator.dispatch(
87 action === "manual-settle"
88 ? {
89 type: "thread.settle",
90 commandId: CommandId.make(`manual-settle-resources:${action}`),
91 threadId,
92 }
93 : {
94 type: "thread.unsettle",
95 commandId: CommandId.make(`revive-resources:${action}`),
96 threadId,
97 reason: "user",
98 },
99 );
100 }
101 const result = yield* orchestrator
102 .dispatch({
103 type: "thread.unsettle",
104 commandId: CommandId.make(`server:auto-settle-resources:${threadId}:1`),
105 threadId,
106 reason: "user",
107 snapshotSequence: automatic.sequence,
108 })
109 .pipe(Effect.exit);
110
111 assert.equal(result._tag, action === "none" ? "Success" : "Failure");
112 const after = yield* projections.getThread(threadId);
113 assert.equal(after.settledOverride, action === "manual-settle" ? "settled" : "active");
114 if (action === "manual-settle") assert.deepStrictEqual(after.settledAt, settledAt);
115 }
116 }).pipe(Effect.provide(layerTest)),
117);
118
55it.effect(119it.effect(
56 "dispatches metadata, queue resume and request controls without hydrating unrelated history",120 "dispatches metadata, queue resume and request controls without hydrating unrelated history",
57 () =>121 () =>
...@@ -960,3 +1024,212 @@ it.effect("wakes the parent once when its delegated task asks a question", () =>...@@ -960,3 +1024,212 @@ it.effect("wakes the parent once when its delegated task asks a question", () =>
960 });1024 });
961 }).pipe(Effect.provide(DelegatedTaskQuestionReactor.layer.pipe(Layer.provideMerge(layerTest)))),1025 }).pipe(Effect.provide(DelegatedTaskQuestionReactor.layer.pipe(Layer.provideMerge(layerTest)))),
962);1026);
1027
1028it.effect("guarded archive preserves a resumed thread and rejects an earlier settlement", () =>
1029 Effect.gen(function* () {
1030 const orchestrator = yield* Orchestrator.OrchestratorV2;
1031 const projections = yield* ProjectionStore.ProjectionStoreV2;
1032 const threadId = ThreadId.make("thread:archive-race");
1033 yield* orchestrator.dispatch({
1034 type: "thread.create",
1035 commandId: CommandId.make("create-archive-race"),
1036 threadId,
1037 projectId: ProjectId.make("project:archive-race"),
1038 title: "Work",
1039 modelSelection,
1040 runtimeMode: "full-access",
1041 interactionMode: "default",
1042 branch: null,
1043 worktreePath: null,
1044 createdBy: "user",
1045 creationSource: "web",
1046 });
1047 yield* orchestrator.dispatch({
1048 type: "thread.settle",
1049 commandId: CommandId.make("settle-archive-race"),
1050 threadId,
1051 });
1052 const { snapshotSequence } = yield* orchestrator.getShellSnapshot();
1053 const originalSettledAt = (yield* projections.getThread(threadId)).settledAt;
1054 yield* orchestrator.dispatch({
1055 type: "thread.settle",
1056 commandId: CommandId.make("duplicate-settle-archive-race"),
1057 threadId,
1058 });
1059 yield* orchestrator.dispatch({
1060 type: "thread.unsettle",
1061 commandId: CommandId.make("resume-archive-race"),
1062 threadId,
1063 reason: "user",
1064 });
1065 const resumed = yield* orchestrator
1066 .dispatch({
1067 type: "thread.archive",
1068 commandId: CommandId.make("archive-resumed"),
1069 threadId,
1070 snapshotSequence,
1071 })
1072 .pipe(Effect.flip);
1073 assert.include(resumed, {
1074 _tag: "OrchestratorDispatchError",
1075 reason: "archive-condition-changed",
1076 });
1077 assert.isNull((yield* projections.getThread(threadId)).archivedAt);
1078
1079 yield* orchestrator.dispatch({
1080 type: "thread.settle",
1081 commandId: CommandId.make("resettle-archive-race"),
1082 threadId,
1083 });
1084 assert.deepEqual((yield* projections.getThread(threadId)).settledAt, originalSettledAt);
1085 const stale = yield* orchestrator
1086 .dispatch({
1087 type: "thread.archive",
1088 commandId: CommandId.make("archive-previous-settlement"),
1089 threadId,
1090 snapshotSequence,
1091 })
1092 .pipe(Effect.flip);
1093 assert.include(stale, {
1094 _tag: "OrchestratorDispatchError",
1095 reason: "archive-condition-changed",
1096 });
1097 assert.isNull((yield* projections.getThread(threadId)).archivedAt);
1098
1099 const beforeRename = yield* orchestrator.getShellSnapshot();
1100 yield* orchestrator.dispatch({
1101 type: "thread.metadata.update",
1102 commandId: CommandId.make("rename-archive-race"),
1103 threadId,
1104 title: "Changed",
1105 });
1106 const renamed = yield* orchestrator
1107 .dispatch({
1108 type: "thread.archive",
1109 commandId: CommandId.make("archive-before-rename"),
1110 threadId,
1111 snapshotSequence: beforeRename.snapshotSequence,
1112 })
1113 .pipe(Effect.flip);
1114 assert.include(renamed, {
1115 _tag: "OrchestratorDispatchError",
1116 reason: "archive-condition-changed",
1117 });
1118 const current = yield* orchestrator.getShellSnapshot();
1119 yield* orchestrator.dispatch({
1120 type: "thread.create",
1121 commandId: CommandId.make("create-unrelated-archive-race"),
1122 threadId: ThreadId.make("thread:unrelated-archive-race"),
1123 projectId: ProjectId.make("project:archive-race"),
1124 title: "Unrelated",
1125 modelSelection,
1126 runtimeMode: "full-access",
1127 interactionMode: "default",
1128 branch: null,
1129 worktreePath: null,
1130 createdBy: "user",
1131 creationSource: "web",
1132 });
1133 yield* orchestrator.dispatch({
1134 type: "thread.archive",
1135 commandId: CommandId.make("archive-current-settlement"),
1136 threadId,
1137 snapshotSequence: current.snapshotSequence,
1138 });
1139 assert.isNotNull((yield* projections.getThread(threadId)).archivedAt);
1140 }).pipe(Effect.provide(layerTest)),
1141);
1142
1143it.effect("guarded archive retains threads with active, queued, or pending request work", () =>
1144 Effect.gen(function* () {
1145 const orchestrator = yield* Orchestrator.OrchestratorV2;
1146 const projections = yield* ProjectionStore.ProjectionStoreV2;
1147 const now = yield* DateTime.now;
1148 for (const status of [
1149 "queued",
1150 "preparing",
1151 "starting",
1152 "running",
1153 "waiting",
1154 "request",
1155 ] as const) {
1156 const threadId = ThreadId.make(`thread:archive-blocked:${status}`);
1157 yield* orchestrator.dispatch({
1158 type: "thread.create",
1159 commandId: CommandId.make(`create-archive-blocked:${status}`),
1160 threadId,
1161 projectId: ProjectId.make("project:archive-blocked"),
1162 title: "Work",
1163 modelSelection,
1164 runtimeMode: "full-access",
1165 interactionMode: "default",
1166 branch: null,
1167 worktreePath: null,
1168 createdBy: "user",
1169 creationSource: "web",
1170 });
1171 yield* orchestrator.dispatch({
1172 type: "thread.settle",
1173 commandId: CommandId.make(`settle-archive-blocked:${status}`),
1174 threadId,
1175 });
1176 if (status === "request") {
1177 yield* projections.apply({
1178 id: EventId.make("archive-blocked:request"),
1179 type: "runtime-request.updated",
1180 threadId,
1181 occurredAt: now,
1182 payload: {
1183 id: RuntimeRequestId.make("archive-blocked:request"),
1184 nodeId: NodeId.make("archive-blocked:request"),
1185 providerTurnId: null,
1186 nativeRequestRef: null,
1187 kind: "user_input",
1188 status: "pending",
1189 responseCapability: { type: "message" },
1190 createdAt: now,
1191 resolvedAt: null,
1192 },
1193 });
1194 } else {
1195 yield* projections.apply({
1196 id: EventId.make(`archive-blocked:run:${status}`),
1197 type: "run.created",
1198 threadId,
1199 occurredAt: now,
1200 payload: {
1201 id: RunId.make(`archive-blocked:run:${status}`),
1202 threadId,
1203 ordinal: 1,
1204 providerInstanceId: instanceId,
1205 modelSelection,
1206 providerThreadId: null,
1207 userMessageId: MessageId.make(`archive-blocked:message:${status}`),
1208 rootNodeId: null,
1209 activeAttemptId: null,
1210 status,
1211 requestedAt: now,
1212 startedAt: null,
1213 completedAt: null,
1214 checkpointId: null,
1215 contextHandoffId: null,
1216 },
1217 });
1218 }
1219 const snapshot = yield* orchestrator.getShellSnapshot();
1220 const exit = yield* orchestrator
1221 .dispatch({
1222 type: "thread.archive",
1223 commandId: CommandId.make(`archive-blocked:${status}`),
1224 threadId,
1225 snapshotSequence: snapshot.snapshotSequence,
1226 })
1227 .pipe(Effect.flip);
1228 assert.include(exit, {
1229 _tag: "OrchestratorDispatchError",
1230 reason: "archive-condition-changed",
1231 });
1232 assert.isNull((yield* projections.getThread(threadId)).archivedAt);
1233 }
1234 }).pipe(Effect.provide(layerTest)),
1235);
apps/server/src/orchestration-v2/Orchestrator.ts+122-107
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { isManagedWorkspaceArchiveCandidate } from "../workspace/managedWorkspaceArchive.ts";
1import type {2import type {
2 OrchestrationV2SearchThreadInput,3 OrchestrationV2SearchThreadInput,
3 OrchestrationV2SearchThreadResult,4 OrchestrationV2SearchThreadResult,
...@@ -142,6 +143,7 @@ export class OrchestratorDispatchError extends Schema.TaggedError<OrchestratorDi...@@ -142,6 +143,7 @@ export class OrchestratorDispatchError extends Schema.TaggedError<OrchestratorDi
142 {143 {
143 commandId: CommandId,144 commandId: CommandId,
144 commandType: Schema.String,145 commandType: Schema.String,
146 reason: Schema.optional(Schema.Literal("archive-condition-changed")),
145 cause: Schema.optional(Schema.Defect()),147 cause: Schema.optional(Schema.Defect()),
146 },148 },
147) {149) {
...@@ -452,6 +454,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {...@@ -452,6 +454,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {
452 case "thread.delete":454 case "thread.delete":
453 case "thread.settle":455 case "thread.settle":
454 case "thread.auto-settle":456 case "thread.auto-settle":
457 case "thread.ci-failed":
455 case "thread.unsettle":458 case "thread.unsettle":
456 case "thread.snooze":459 case "thread.snooze":
457 case "thread.unsnooze":460 case "thread.unsnooze":
...@@ -2470,6 +2473,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -2470,6 +2473,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2470 return yield* new OrchestratorDispatchError({2473 return yield* new OrchestratorDispatchError({
2471 commandId: command.commandId,2474 commandId: command.commandId,
2472 commandType: command.type,2475 commandType: command.type,
2476 ...(command.type === "thread.archive" && command.snapshotSequence !== undefined
2477 ? { reason: "archive-condition-changed" as const }
2478 : {}),
2473 cause: `Thread ${command.threadId} is deleted.`,2479 cause: `Thread ${command.threadId} is deleted.`,
2474 });2480 });
2475 }2481 }
...@@ -2553,6 +2559,45 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -2553,6 +2559,45 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2553 cause: `Thread ${command.threadId} is no longer empty.`,2559 cause: `Thread ${command.threadId} is no longer empty.`,
2554 });2560 });
2555 }2561 }
2562 if (command.type === "thread.unsettle" && command.snapshotSequence !== undefined) {
2563 const sequence = yield* eventSink
2564 .latestSequence({ threadId: command.threadId })
2565 .pipe(mapDispatchError(command));
2566 if (thread.settledOverride !== "settled" || sequence > command.snapshotSequence) {
2567 return yield* new OrchestratorDispatchError({
2568 commandId: command.commandId,
2569 commandType: command.type,
2570 cause: `Thread ${command.threadId} changed before automatic settlement could be cancelled.`,
2571 });
2572 }
2573 }
2574 if (command.type === "thread.archive" && command.snapshotSequence !== undefined) {
2575 const shell = yield* projectionStore
2576 .getThreadShell(command.threadId)
2577 .pipe(mapDispatchError(command));
2578 const records = yield* projectionStore
2579 .getThreadRecords(command.threadId, ["runs", "runtimeRequests"])
2580 .pipe(mapDispatchError(command));
2581 const sequence = yield* eventSink
2582 .latestSequence({ threadId: command.threadId })
2583 .pipe(mapDispatchError(command));
2584 if (
2585 shell === null ||
2586 !isManagedWorkspaceArchiveCandidate(shell) ||
2587 sequence > command.snapshotSequence ||
2588 records.runs.some((run) =>
2589 ["queued", "preparing", "starting", "running", "waiting"].includes(run.status),
2590 ) ||
2591 records.runtimeRequests.some((request) => request.status === "pending")
2592 ) {
2593 return yield* new OrchestratorDispatchError({
2594 commandId: command.commandId,
2595 commandType: command.type,
2596 reason: "archive-condition-changed",
2597 cause: `Thread ${command.threadId} changed before automatic archival.`,
2598 });
2599 }
2600 }
2556 if (command.type === "thread.archive" && thread.archivedAt !== null) {2601 if (command.type === "thread.archive" && thread.archivedAt !== null) {
2557 return yield* new OrchestratorDispatchError({2602 return yield* new OrchestratorDispatchError({
2558 commandId: command.commandId,2603 commandId: command.commandId,
...@@ -10863,6 +10908,82 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -10863,6 +10908,82 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
10863 case "thread.visit":10908 case "thread.visit":
10864 yield* dispatchThreadVisit(command, events);10909 yield* dispatchThreadVisit(command, events);
10865 break;10910 break;
10911 case "thread.ci-failed": {
10912 const thread = yield* projectionStore
10913 .getThreadShell(command.threadId)
10914 .pipe(mapDispatchError(command));
10915 const sequence = yield* eventSink
10916 .latestSequence({ threadId: command.threadId })
10917 .pipe(mapDispatchError(command));
10918 const records = yield* projectionStore
10919 .getThreadRecords(command.threadId, ["runs", "runtimeRequests"])
10920 .pipe(mapDispatchError(command));
10921 if (
10922 thread === null ||
10923 !isManagedWorkspaceArchiveCandidate(thread) ||
10924 sequence > command.snapshotSequence ||
10925 records.runs.some((run) =>
10926 ["queued", "preparing", "starting", "running", "waiting"].includes(run.status),
10927 ) ||
10928 records.runtimeRequests.some((request) => request.status === "pending")
10929 ) {
10930 return yield* new OrchestratorDispatchError({
10931 commandId: command.commandId,
10932 commandType: command.type,
10933 cause: "The thread changed before the CI failure was recorded.",
10934 });
10935 }
10936 if (
10937 yield* projectionStore
10938 .getTurnItem({ threadId: command.threadId, itemId: command.itemId })
10939 .pipe(mapDispatchError(command))
10940 )
10941 break;
10942 const now = yield* DateTime.now;
10943 yield* dispatchThreadMutation(
10944 {
10945 type: "thread.unsettle",
10946 commandId: command.commandId,
10947 threadId: command.threadId,
10948 reason: "user",
10949 },
10950 events,
10951 effects,
10952 );
10953 const ordinal = yield* projectionStore
10954 .getNextTurnItemOrdinal(command.threadId)
10955 .pipe(mapDispatchError(command));
10956 yield* emit(
10957 events,
10958 command,
10959 )({
10960 type: "turn-item.updated",
10961 threadId: command.threadId,
10962 occurredAt: now,
10963 payload: {
10964 type: "notification",
10965 source: { kind: "system" },
10966 outcome: "failed",
10967 summary: `CI failed for PR #${command.pullRequestNumber}`,
10968 detail: `[Open PR #${command.pullRequestNumber} in Mergebot](<${command.url}>)`,
10969 id: command.itemId,
10970 threadId: command.threadId,
10971 runId: null,
10972 nodeId: null,
10973 providerThreadId: null,
10974 providerTurnId: null,
10975 nativeItemRef: null,
10976 parentItemId: null,
10977 ordinal,
10978 status: "completed",
10979 title: null,
10980 startedAt: now,
10981 completedAt: now,
10982 updatedAt: now,
10983 },
10984 });
10985 break;
10986 }
10866 case "thread.auto-settle": {10987 case "thread.auto-settle": {
10867 // Automatic settlement (#8600): the sweep evaluated a shell snapshot,10988 // Automatic settlement (#8600): the sweep evaluated a shell snapshot,
10868 // so re-check against the live thread before settling. Any change10989 // so re-check against the live thread before settling. Any change
...@@ -11212,6 +11333,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -11212,6 +11333,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
11212 planned.events.length > 0 ||11333 planned.events.length > 0 ||
11213 command.type === "thread.background-work.settle" ||11334 command.type === "thread.background-work.settle" ||
11214 command.type === "thread.stop" ||11335 command.type === "thread.stop" ||
11336 command.type === "thread.ci-failed" ||
11215 command.type === "queued-message.promote-to-steer" ||11337 command.type === "queued-message.promote-to-steer" ||
11216 command.type === "subagent.stop"11338 command.type === "subagent.stop"
11217 ? Effect.succeed(planned)11339 ? Effect.succeed(planned)
...@@ -11724,110 +11846,3 @@ export const layer: Layer.Layer<...@@ -11724,110 +11846,3 @@ export const layer: Layer.Layer<
11724> = Layer.effect(OrchestratorV2, makeOrchestrator()).pipe(11846> = Layer.effect(OrchestratorV2, makeOrchestrator()).pipe(
11725 Layer.provide(ThreadCommandExecutor.layer),11847 Layer.provide(ThreadCommandExecutor.layer),
11726);11848);
11727
11728const layerUnavailable: Layer.Layer<OrchestratorV2> = Layer.succeed(
11729 OrchestratorV2,
11730 OrchestratorV2.of({
11731 resumeQueuedRuns: Effect.fail(
11732 new OrchestratorDispatchError({
11733 commandId: CommandId.make("command:system:resume-queued-runs"),
11734 commandType: "message.dispatch",
11735 cause: "Orchestration V2 live runtime is not configured.",
11736 }),
11737 ),
11738 recoverDelegatedTasks: Effect.void,
11739 recoverDelegatedTask: () => Effect.void,
11740 delegatedTaskResultPending: () => Effect.succeed(false),
11741 restartContinuationPending: () => Effect.succeed(false),
11742 awaitRestartContinuation: () => Effect.void,
11743 dispatch: (command) =>
11744 Effect.fail(
11745 new OrchestratorDispatchError({
11746 commandId: command.commandId,
11747 commandType: command.type,
11748 cause: "Orchestration V2 live runtime is not configured.",
11749 }),
11750 ),
11751 searchThreadStream: (input) =>
11752 Stream.fail(new OrchestratorProjectionError({ threadId: input.threadId })),
11753 searchThread: (input) =>
11754 Effect.fail(new OrchestratorProjectionError({ threadId: input.threadId })),
11755 getThreadHistoryPage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),
11756 getTimelinePage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),
11757 getMessageCount: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),
11758 getTurnItem: ({ threadId }) => Effect.fail(new OrchestratorProjectionError({ threadId })),
11759 getThreadRecords: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),
11760 getThreadProjection: (threadId) =>
11761 Effect.fail(
11762 new OrchestratorProjectionError({
11763 threadId,
11764 cause: "Orchestration V2 live runtime is not configured.",
11765 }),
11766 ),
11767 getCheckpointContext: (threadId) =>
11768 Effect.fail(
11769 new OrchestratorProjectionError({
11770 threadId,
11771 cause: "Orchestration V2 live runtime is not configured.",
11772 }),
11773 ),
11774 getThreadSnapshot: (threadId) =>
11775 Effect.fail(
11776 new OrchestratorProjectionError({
11777 threadId,
11778 cause: "Orchestration V2 live runtime is not configured.",
11779 }),
11780 ),
11781 getThreadSnapshotWindow: (threadId) =>
11782 Effect.fail(
11783 new OrchestratorProjectionError({
11784 threadId,
11785 cause: "Orchestration V2 live runtime is not configured.",
11786 }),
11787 ),
11788 getShellSnapshot: () =>
11789 Effect.fail(
11790 new OrchestratorProjectionError({
11791 threadId: ThreadId.make("thread:shell"),
11792 cause: "Orchestration V2 live runtime is not configured.",
11793 }),
11794 ),
11795 readShellSnapshot: () =>
11796 Effect.fail(
11797 new OrchestratorProjectionError({
11798 threadId: ThreadId.make("thread:shell"),
11799 cause: "Orchestration V2 live runtime is not configured.",
11800 }),
11801 ),
11802 getThreadShell: (threadId) =>
11803 Effect.fail(
11804 new OrchestratorProjectionError({
11805 threadId,
11806 cause: "Orchestration V2 live runtime is not configured.",
11807 }),
11808 ),
11809 getThreadEventSequence: (threadId) =>
11810 Effect.fail(
11811 new OrchestratorProjectionError({
11812 threadId,
11813 cause: "Orchestration V2 live runtime is not configured.",
11814 }),
11815 ),
11816 streamStoredEvents: Stream.fail(
11817 new OrchestratorDomainEventStreamError({
11818 cause: "Orchestration V2 live runtime is not configured.",
11819 }),
11820 ),
11821 streamStoredEventsFrom: () =>
11822 Stream.fail(
11823 new OrchestratorDomainEventStreamError({
11824 cause: "Orchestration V2 live runtime is not configured.",
11825 }),
11826 ),
11827 streamDomainEvents: Stream.fail(
11828 new OrchestratorDomainEventStreamError({
11829 cause: "Orchestration V2 live runtime is not configured.",
11830 }),
11831 ),
11832 } satisfies OrchestratorV2Shape),
11833);
apps/server/src/orchestration-v2/ProjectionStore.ts+17-1
...@@ -370,6 +370,7 @@ export interface ShellSnapshotOptions {...@@ -370,6 +370,7 @@ export interface ShellSnapshotOptions {
370 * their run, item or session rows are read.370 * their run, item or session rows are read.
371 */371 */
372 readonly unsettledOnly?: boolean;372 readonly unsettledOnly?: boolean;
373 readonly settledOnly?: boolean;
373}374}
374375
375export interface ProjectionStoreV2Shape {376export interface ProjectionStoreV2Shape {
...@@ -5308,7 +5309,12 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -5308,7 +5309,12 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
53085309
5309 const selectShellThreadRows = (5310 const selectShellThreadRows = (
5310 threadId?: ThreadId,5311 threadId?: ThreadId,
5311 { location, projectId, unsettledOnly = false }: ShellSnapshotOptions = {},5312 {
5313 location,
5314 projectId,
5315 unsettledOnly = false,
5316 settledOnly = false,
5317 }: ShellSnapshotOptions = {},
5312 ) =>5318 ) =>
5313 sql<ShellThreadRow>`5319 sql<ShellThreadRow>`
5314 SELECT5320 SELECT
...@@ -5494,6 +5500,10 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -5494,6 +5500,10 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
5494 unsettledOnly5500 unsettledOnly
5495 ? sql` AND json_extract(t.payload_json, '$.settledAt') IS NULL AND json_extract(t.payload_json, '$.settledOverride') IS NOT 'settled'`5501 ? sql` AND json_extract(t.payload_json, '$.settledAt') IS NULL AND json_extract(t.payload_json, '$.settledOverride') IS NOT 'settled'`
5496 : sql``5502 : sql``
5503 }${
5504 settledOnly
5505 ? sql` AND json_extract(t.payload_json, '$.settledOverride') = 'settled' AND json_extract(t.payload_json, '$.settledAt') IS NOT NULL`
5506 : sql``
5497 }5507 }
5498 ORDER BY t.updated_at ASC, t.thread_id ASC5508 ORDER BY t.updated_at ASC, t.thread_id ASC
5499 `;5509 `;
...@@ -6196,6 +6206,12 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(...@@ -6196,6 +6206,12 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(
6196 ) {6206 ) {
6197 return false;6207 return false;
6198 }6208 }
6209 if (
6210 options?.settledOnly &&
6211 (projection.thread.settledOverride !== "settled" ||
6212 projection.thread.settledAt === null)
6213 )
6214 return false;
6199 if (options?.location === "active") return projection.thread.archivedAt === null;6215 if (options?.location === "active") return projection.thread.archivedAt === null;
6200 if (options?.location === "archive") return projection.thread.archivedAt !== null;6216 if (options?.location === "archive") return projection.thread.archivedAt !== null;
6201 return true;6217 return true;
apps/server/src/orchestration-v2/ThreadAutoArchive.integration.test.ts created+279
...@@ -0,0 +1,279 @@
1import { assert, it } from "@effect/vitest";
2import {
3 CommandId,
4 MessageId,
5 ProjectId,
6 ProviderDriverKind,
7 ProviderInstanceId,
8 ThreadId,
9 TurnItemId,
10} from "@t3tools/contracts";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Exit from "effect/Exit";
14import * as SqlClient from "effect/sql/SqlClient";
15import * as SqlitePersistence from "../persistence/Sqlite.ts";
16import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
17import * as Orchestrator from "./Orchestrator.ts";
18import * as ProjectionStore from "./ProjectionStore.ts";
19import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
20import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts";
21import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
22
23const instanceId = ProviderInstanceId.make("codex");
24const modelSelection = { instanceId, model: "gpt-5.1-codex" };
25const adapter = {
26 instanceId,
27 driver: ProviderDriverKind.make("codex"),
28 getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2),
29 planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }),
30 openSession: () => Effect.die("A CI failure must not start a provider"),
31} as ProviderAdapter.ProviderAdapterV2["Service"];
32const databaseLayer = SqlitePersistence.layerMemory;
33const testLayer = Layer.mergeAll(
34 databaseLayer,
35 ProjectionStore.layer.pipe(Layer.provide(databaseLayer)),
36 ProviderReplayHarness.layerWithRegistry(
37 { name: "auto-archive" },
38 ProviderAdapterRegistry.layerFromAdapters([adapter]),
39 { databaseLayer, runEffectWorker: false },
40 ),
41);
42
43it.effect(
44 "CI failure wakes atomically with a durable local notification, deduplicates across settlement, and never requests provider work",
45 () =>
46 Effect.gen(function* () {
47 const engine = yield* Orchestrator.OrchestratorV2;
48 const projections = yield* ProjectionStore.ProjectionStoreV2;
49 const sql = yield* SqlClient.SqlClient;
50 const threadId = ThreadId.make("ci-notification");
51 yield* engine.dispatch({
52 type: "thread.create",
53 commandId: CommandId.make("create-ci"),
54 threadId,
55 projectId: ProjectId.make("project-ci"),
56 title: "CI notification",
57 modelSelection,
58 runtimeMode: "full-access",
59 interactionMode: "default",
60 branch: null,
61 worktreePath: null,
62 createdBy: "user",
63 creationSource: "web",
64 });
65 yield* engine.dispatch({
66 type: "thread.settle",
67 commandId: CommandId.make("settle-ci"),
68 threadId,
69 });
70 assert.deepStrictEqual(
71 (yield* projections.getShellSnapshot({
72 location: "active",
73 settledOnly: true,
74 })).threads.map((thread) => thread.id),
75 [threadId],
76 );
77 const itemId = TurnItemId.make("ci-head-notice");
78 const dispatchFailure = (commandId: string, snapshotSequence: number) =>
79 engine.dispatch({
80 type: "thread.ci-failed",
81 commandId: CommandId.make(commandId),
82 threadId,
83 itemId,
84 snapshotSequence,
85 pullRequestNumber: 42,
86 url: "https://mergebot.example.test/owner/repository/pull/42",
87 });
88 yield* dispatchFailure("wake-ci", (yield* projections.getShellSnapshot()).snapshotSequence);
89 const thread = yield* projections.getThread(threadId);
90 assert.equal(thread.settledOverride, "active");
91 assert.equal(thread.settledAt, null);
92 assert.equal(
93 (yield* projections.getShellSnapshot({ location: "active", settledOnly: true })).threads
94 .length,
95 0,
96 );
97 const item = yield* projections.getTurnItem({ threadId, itemId });
98 assert.ok(item?.type === "notification");
99 assert.deepStrictEqual(item.source, { kind: "system" });
100 assert.equal(item.summary, "CI failed for PR #42");
101 assert.equal(
102 item.detail,
103 "[Open PR #42 in Mergebot](<https://mergebot.example.test/owner/repository/pull/42>)",
104 );
105 assert.equal(item.runId, null);
106 assert.equal(
107 (yield* projections.getThreadRecords(threadId, ["runs", "messages"])).runs.length,
108 0,
109 );
110 assert.equal(
111 (yield* projections.getThreadRecords(threadId, ["messages"])).messages.length,
112 0,
113 );
114 const effects = yield* sql<{
115 type: string;
116 }>`SELECT effect_type AS type FROM orchestration_v2_effect_outbox`;
117 assert.ok(
118 effects.every(
119 (effect) =>
120 ![
121 "provider-runtime.continue",
122 "provider-turn.start",
123 "provider-turn.steer",
124 "provider-turn.restart",
125 ].includes(effect.type),
126 ),
127 );
128 yield* engine.dispatch({
129 type: "thread.settle",
130 commandId: CommandId.make("resettle-ci"),
131 threadId,
132 });
133 yield* dispatchFailure(
134 "duplicate-ci",
135 (yield* projections.getShellSnapshot()).snapshotSequence,
136 );
137 assert.equal((yield* projections.getThread(threadId)).settledOverride, "settled");
138 assert.equal(
139 (yield* projections.getThreadRecords(threadId, ["turnItems"])).turnItems.length,
140 1,
141 );
142 }).pipe(Effect.provide(testLayer)),
143);
144
145it.effect(
146 "a concurrent activation or edit invalidates pending automatic archival and CI wakeup",
147 () =>
148 Effect.gen(function* () {
149 const engine = yield* Orchestrator.OrchestratorV2;
150 const projections = yield* ProjectionStore.ProjectionStoreV2;
151 const threadId = ThreadId.make("archive-race");
152 yield* engine.dispatch({
153 type: "thread.create",
154 commandId: CommandId.make("create-race"),
155 threadId,
156 projectId: ProjectId.make("project-ci"),
157 title: "Archive race",
158 modelSelection,
159 runtimeMode: "full-access",
160 interactionMode: "default",
161 branch: null,
162 worktreePath: null,
163 createdBy: "user",
164 creationSource: "web",
165 });
166 yield* engine.dispatch({
167 type: "thread.settle",
168 commandId: CommandId.make("settle-race"),
169 threadId,
170 });
171 const before = (yield* projections.getShellSnapshot()).snapshotSequence;
172 yield* engine.dispatch({
173 type: "thread.unsettle",
174 commandId: CommandId.make("activate-race"),
175 threadId,
176 reason: "user",
177 });
178 yield* engine.dispatch({
179 type: "thread.settle",
180 commandId: CommandId.make("resettle-race"),
181 threadId,
182 });
183 for (const command of [
184 {
185 type: "thread.archive" as const,
186 commandId: CommandId.make("stale-archive"),
187 threadId,
188 snapshotSequence: before,
189 },
190 {
191 type: "thread.ci-failed" as const,
192 commandId: CommandId.make("stale-ci"),
193 threadId,
194 snapshotSequence: before,
195 itemId: TurnItemId.make("stale-notice"),
196 pullRequestNumber: 42,
197 url: "https://mergebot.example.test/owner/repository/pull/42",
198 },
199 ])
200 assert.ok(Exit.isFailure(yield* Effect.exit(engine.dispatch(command))));
201 assert.equal((yield* projections.getThread(threadId)).archivedAt, null);
202 assert.equal(
203 (yield* projections.getThreadRecords(threadId, ["turnItems"])).turnItems.length,
204 0,
205 );
206 yield* engine.dispatch({
207 type: "thread.archive",
208 commandId: CommandId.make("fresh-archive"),
209 threadId,
210 snapshotSequence: (yield* projections.getShellSnapshot()).snapshotSequence,
211 });
212 assert.notEqual((yield* projections.getThread(threadId)).archivedAt, null);
213 }).pipe(Effect.provide(testLayer)),
214);
215
216it.effect(
217 "a new user turn prevents a pending archive or CI observation from changing the thread",
218 () =>
219 Effect.gen(function* () {
220 const engine = yield* Orchestrator.OrchestratorV2;
221 const projections = yield* ProjectionStore.ProjectionStoreV2;
222 const threadId = ThreadId.make("new-run-race");
223 yield* engine.dispatch({
224 type: "thread.create",
225 commandId: CommandId.make("create-new-run"),
226 threadId,
227 projectId: ProjectId.make("project-ci"),
228 title: "New run race",
229 modelSelection,
230 runtimeMode: "full-access",
231 interactionMode: "default",
232 branch: null,
233 worktreePath: null,
234 createdBy: "user",
235 creationSource: "web",
236 });
237 yield* engine.dispatch({
238 type: "thread.settle",
239 commandId: CommandId.make("settle-new-run"),
240 threadId,
241 });
242 const snapshotSequence = (yield* projections.getShellSnapshot()).snapshotSequence;
243 yield* engine.dispatch({
244 type: "message.dispatch",
245 commandId: CommandId.make("new-user-turn"),
246 threadId,
247 messageId: MessageId.make("new-message"),
248 text: "Continue this work",
249 attachments: [],
250 dispatchMode: { type: "queue_after_active" },
251 createdBy: "user",
252 creationSource: "web",
253 });
254 for (const command of [
255 {
256 type: "thread.archive" as const,
257 commandId: CommandId.make("archive-after-new-run"),
258 threadId,
259 snapshotSequence,
260 },
261 {
262 type: "thread.ci-failed" as const,
263 commandId: CommandId.make("ci-after-new-run"),
264 threadId,
265 snapshotSequence,
266 itemId: TurnItemId.make("new-run-notice"),
267 pullRequestNumber: 42,
268 url: "https://mergebot.example.test/owner/repository/pull/42",
269 },
270 ])
271 assert.ok(Exit.isFailure(yield* Effect.exit(engine.dispatch(command))));
272 assert.equal((yield* projections.getThread(threadId)).archivedAt, null);
273 assert.equal(
274 yield* projections.getTurnItem({ threadId, itemId: TurnItemId.make("new-run-notice") }),
275 null,
276 );
277 assert.equal((yield* projections.getThreadRecords(threadId, ["runs"])).runs.length, 1);
278 }).pipe(Effect.provide(testLayer)),
279);
apps/server/src/orchestration-v2/ThreadSettlementService.test.ts+1285-54
...@@ -3,18 +3,26 @@ import { assert, describe, expect, it } from "@effect/vitest";...@@ -3,18 +3,26 @@ import { assert, describe, expect, it } from "@effect/vitest";
3import * as DateTime from "effect/DateTime";3import * as DateTime from "effect/DateTime";
4import {4import {
5 DEFAULT_SERVER_SETTINGS,5 DEFAULT_SERVER_SETTINGS,
6 CommandId,
6 ProjectId,7 ProjectId,
7 EventId,8 EventId,
9 RunId,
10 TurnItemId,
11 ManagedWorkspaceError,
12 DevServerError,
8 ProviderInstanceId,13 ProviderInstanceId,
9 ProviderSessionId,14 ProviderSessionId,
10 ThreadId,15 ThreadId,
11 type OrchestrationProjectShell,16 type OrchestrationProjectShell,
12 type OrchestrationV2AppThread,17 type OrchestrationV2AppThread,
13 type OrchestrationV2Command,
14 type OrchestrationV2DomainEvent,18 type OrchestrationV2DomainEvent,
19 type OrchestrationV2StoredEvent,
20 type TerminalSummary,
15 type OrchestrationV2ShellSnapshot,21 type OrchestrationV2ShellSnapshot,
16 type OrchestrationV2ThreadShell,22 type OrchestrationV2ThreadShell,
17 type PullRequestSummary,23 type PullRequestSummary,
24 type MergebotPullRequestDetails,
25 type OrchestrationV2ServerCommand,
18 type ServerSettings as ContractServerSettings,26 type ServerSettings as ContractServerSettings,
19 type ServerSettingsPatch,27 type ServerSettingsPatch,
20} from "@t3tools/contracts";28} from "@t3tools/contracts";
...@@ -22,6 +30,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";...@@ -22,6 +30,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
22import * as Crypto from "effect/Crypto";30import * as Crypto from "effect/Crypto";
23import * as Deferred from "effect/Deferred";31import * as Deferred from "effect/Deferred";
24import * as Effect from "effect/Effect";32import * as Effect from "effect/Effect";
33import * as Fiber from "effect/Fiber";
25import * as FileSystem from "effect/FileSystem";34import * as FileSystem from "effect/FileSystem";
26import * as Layer from "effect/Layer";35import * as Layer from "effect/Layer";
27import * as PubSub from "effect/PubSub";36import * as PubSub from "effect/PubSub";
...@@ -36,10 +45,15 @@ import * as PullRequestService from "../pullRequest/PullRequestService.ts";...@@ -36,10 +45,15 @@ import * as PullRequestService from "../pullRequest/PullRequestService.ts";
36import * as ServerActivation from "../serverActivation.ts";45import * as ServerActivation from "../serverActivation.ts";
37import * as ServerSettings from "../serverSettings.ts";46import * as ServerSettings from "../serverSettings.ts";
38import * as TerminalManager from "../terminal/Manager.ts";47import * as TerminalManager from "../terminal/Manager.ts";
48import * as PreviewManager from "../preview/Manager.ts";
39import * as ProjectStore from "./ProjectStore.ts";49import * as ProjectStore from "./ProjectStore.ts";
40import * as Orchestrator from "./Orchestrator.ts";50import * as Orchestrator from "./Orchestrator.ts";
41import * as ProjectionStore from "./ProjectionStore.ts";51import * as ProjectionStore from "./ProjectionStore.ts";
42import * as ThreadSettlementService from "./ThreadSettlementService.ts";52import * as ThreadSettlementService from "./ThreadSettlementService.ts";
53import * as ManagedWorkspaces from "../workspace/ManagedWorkspaces.ts";
54import * as DevServer from "../workspace/DevServer.ts";
55import * as MergebotQueue from "../mergebot/MergebotQueue.ts";
56import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts";
4357
44const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z");58const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z");
45const DAY_MS = 24 * 60 * 60 * 1_000;59const DAY_MS = 24 * 60 * 60 * 1_000;
...@@ -152,7 +166,7 @@ describe("isAutoSettlementCandidate", () => {...@@ -152,7 +166,7 @@ describe("isAutoSettlementCandidate", () => {
152 ).toBe(false);166 ).toBe(false);
153 });167 });
154168
155 it("settles a thread whose only background work is a command left running", () => {169 it("keeps a thread with a running background command active", () => {
156 expect(170 expect(
157 ThreadSettlementService.isAutoSettlementCandidate(171 ThreadSettlementService.isAutoSettlementCandidate(
158 shell({172 shell({
...@@ -162,7 +176,7 @@ describe("isAutoSettlementCandidate", () => {...@@ -162,7 +176,7 @@ describe("isAutoSettlementCandidate", () => {
162 }),176 }),
163 NOW_MS,177 NOW_MS,
164 ),178 ),
165 ).toBe(true);179 ).toBe(false);
166 });180 });
167181
168 it("keeps snoozed threads parked until they wake early on error or completion", () => {182 it("keeps snoozed threads parked until they wake early on error or completion", () => {
...@@ -251,6 +265,34 @@ describe("threadHasQueuedTurnStart", () => {...@@ -251,6 +265,34 @@ describe("threadHasQueuedTurnStart", () => {
251});265});
252266
253describe("resolveAutoSettlementAt", () => {267describe("resolveAutoSettlementAt", () => {
268 it("keeps canonical work PR links while ignoring their cached lifecycle", () => {
269 const input = {
270 thread: shell({
271 latestUserMessageAt: at(-4 * DAY_MS),
272 pullRequests: [link(1)],
273 }),
274 pullRequest: null,
275 usePullRequestSnapshots: false,
276 nowMs: NOW_MS,
277 autoSettleAfterDays: 3,
278 autoSettleOnMerge: true,
279 autoSettleKeepPullRequests: true,
280 };
281 expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toBeNull();
282 expect(
283 ThreadSettlementService.resolveAutoSettlementAt({
284 ...input,
285 autoSettleKeepPullRequests: false,
286 }),
287 ).toEqual(at(-4 * DAY_MS));
288 expect(
289 ThreadSettlementService.resolveAutoSettlementAt({
290 ...input,
291 pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) },
292 }),
293 ).toEqual(at(-4 * DAY_MS));
294 });
295
254 it("keeps threads with a PR from inactivity settlement only when asked", () => {296 it("keeps threads with a PR from inactivity settlement only when asked", () => {
255 const input = {297 const input = {
256 thread: shell({298 thread: shell({
...@@ -416,6 +458,24 @@ const PROJECT_ID = ProjectId.make("settlement-project");...@@ -416,6 +458,24 @@ const PROJECT_ID = ProjectId.make("settlement-project");
416const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project");458const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project");
417459
418describe("autoSettlementSettingsKey", () => {460describe("autoSettlementSettingsKey", () => {
461 it("tracks PR retention changes at environment and project scope", () => {
462 const base = ThreadSettlementService.autoSettlementSettingsKey(DEFAULT_SERVER_SETTINGS);
463 expect(
464 ThreadSettlementService.autoSettlementSettingsKey({
465 ...DEFAULT_SERVER_SETTINGS,
466 sidebarAutoSettleKeepPullRequests: true,
467 }),
468 ).not.toBe(base);
469 expect(
470 ThreadSettlementService.autoSettlementSettingsKey({
471 ...DEFAULT_SERVER_SETTINGS,
472 projectSettingsOverrides: {
473 [PROJECT_ID]: { sidebarAutoSettleKeepPullRequests: true },
474 },
475 }),
476 ).not.toBe(base);
477 });
478
419 it("distinguishes a project that inherits the threshold from one that disables it", () => {479 it("distinguishes a project that inherits the threshold from one that disables it", () => {
420 const inherits = ThreadSettlementService.autoSettlementSettingsKey({480 const inherits = ThreadSettlementService.autoSettlementSettingsKey({
421 ...DEFAULT_SERVER_SETTINGS,481 ...DEFAULT_SERVER_SETTINGS,
...@@ -446,7 +506,12 @@ describe("autoSettlementSettingsKey", () => {...@@ -446,7 +506,12 @@ describe("autoSettlementSettingsKey", () => {
446 });506 });
447});507});
448508
449type AutoSettleCommand = Extract<OrchestrationV2Command, { readonly type: "thread.auto-settle" }>;509type SettlementCommand = Extract<
510 OrchestrationV2ServerCommand,
511 {
512 readonly type: "thread.auto-settle" | "thread.archive" | "thread.ci-failed" | "thread.unsettle";
513 }
514>;
450515
451const testCrypto = Crypto.make({516const testCrypto = Crypto.make({
452 randomBytes: (size) => new Uint8Array(size).fill(1),517 randomBytes: (size) => new Uint8Array(size).fill(1),
...@@ -468,16 +533,19 @@ function makeProject(...@@ -468,16 +533,19 @@ function makeProject(
468 };533 };
469}534}
470535
471function makeThread(id: string, overrides: Partial<SettlementShell> = {}): SettlementShell {536function makeThread(id: string, overrides: Partial<SettlementShell> = {}) {
472 return shell({537 return {
473 id: ThreadId.make(id),538 ...shell({
474 projectId: PROJECT_ID,539 id: ThreadId.make(id),
475 title: id,540 projectId: PROJECT_ID,
476 createdAt: DateTime.makeUnsafe("2026-08-01T00:00:00.000Z"),541 title: id,
477 updatedAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"),542 createdAt: DateTime.makeUnsafe("2026-08-01T00:00:00.000Z"),
478 latestUserMessageAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"),543 updatedAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"),
479 ...overrides,544 latestUserMessageAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"),
480 });545 ...overrides,
546 }),
547 lastVisitedAt: overrides.lastVisitedAt ?? null,
548 };
481}549}
482550
483type SettlementSnapshot = Omit<OrchestrationV2ShellSnapshot, "threads"> & {551type SettlementSnapshot = Omit<OrchestrationV2ShellSnapshot, "threads"> & {
...@@ -533,19 +601,109 @@ function makeBranchPullRequest(state: "open" | "closed" | "merged") {...@@ -533,19 +601,109 @@ function makeBranchPullRequest(state: "open" | "closed" | "merged") {
533 } satisfies GitManager.GitBranchPullRequest;601 } satisfies GitManager.GitBranchPullRequest;
534}602}
535603
604function terminal(threadId: ThreadId): TerminalSummary {
605 return {
606 threadId,
607 terminalId: "default",
608 cwd: "/workspace/project",
609 worktreePath: null,
610 status: "running",
611 pid: 123,
612 exitCode: null,
613 exitSignal: null,
614 hasRunningSubprocess: true,
615 label: "command",
616 updatedAt: NOW,
617 };
618}
619
620function link(number: number) {
621 return {
622 host: "github.com",
623 repository: "owner/repository",
624 number,
625 url: `https://github.com/owner/repository/pull/${number}`,
626 source: "manual" as const,
627 linkedAt: NOW,
628 snapshot: null,
629 stack: null,
630 };
631}
632
633function mergebotDetail(
634 number: number,
635 overrides: Partial<MergebotPullRequestDetails> = {},
636): MergebotPullRequestDetails {
637 return {
638 number,
639 headSha: "current-head",
640 lifecycle: "open",
641 queueStatus: "not_queued",
642 position: null,
643 mergeEtaAt: null,
644 url: `https://mergebot.example.test/owner/repository/pull/${number}`,
645 title: "Pull request",
646 createdAt: NOW,
647 author: null,
648 additions: 1,
649 deletions: 0,
650 changedFilesCount: 1,
651 labels: [],
652 comments: [],
653 observedAt: Date.parse(NOW),
654 updatedAt: NOW,
655 mergedAt: null,
656 closedAt: null,
657 body: null,
658 draft: false,
659 baseBranch: "main",
660 headBranch: "feature",
661 ciStatus: "passed",
662 hasConflicts: false,
663 checks: [],
664 ...overrides,
665 };
666}
667
668function failedCheck(
669 overrides: Partial<MergebotPullRequestDetails["checks"][number]> = {},
670): MergebotPullRequestDetails["checks"][number] {
671 return {
672 id: "check-1",
673 name: "build",
674 workflowName: null,
675 status: "completed",
676 conclusion: "failure",
677 url: null,
678 startedAt: null,
679 completedAt: NOW,
680 ...overrides,
681 };
682}
683
536interface HarnessOptions {684interface HarnessOptions {
537 readonly snapshot: SettlementSnapshot;685 readonly snapshot: SettlementSnapshot;
538 readonly settings?: ContractServerSettings;686 readonly settings?: ContractServerSettings;
539 readonly branchPullRequest?: GitManager.GitManager["Service"]["branchPullRequest"];687 readonly branchPullRequest?: GitManager.GitManager["Service"]["branchPullRequest"];
540 readonly pullRequestSummary?: PullRequestService.PullRequestService["Service"]["summary"];688 readonly pullRequestSummary?: PullRequestService.PullRequestService["Service"]["summary"];
541 readonly existingWorktreePaths?: ReadonlyArray<string>;689 readonly existingWorktreePaths?: ReadonlyArray<string>;
542 readonly onDispatch?: (command: AutoSettleCommand) => Effect.Effect<void>;690 readonly managedThreadIds?: ReadonlyArray<ThreadId>;
691 readonly managedLookup?: ManagedWorkspaces.ManagedWorkspaces["Service"]["isManagedThread"];
692 readonly pullRequestDetail?: PullRequestService.PullRequestService["Service"]["detail"];
693 readonly mergebotDetail?: MergebotQueue.MergebotQueue["Service"]["readPullRequest"];
694 readonly mergebotFind?: MergebotQueue.MergebotQueue["Service"]["findPullRequest"];
695 readonly previousCiNotice?: boolean;
696 readonly previousItemIds?: ReadonlySet<TurnItemId>;
697 readonly settlementTimes?: ReadonlyMap<ThreadId, DateTime.Utc | null>;
698 readonly onDispatch?: (command: SettlementCommand) => Effect.Effect<void>;
543 /** Runs before each settle script start; a failure fails that start. */699 /** Runs before each settle script start; a failure fails that start. */
544 readonly onScriptRun?: (700 readonly onScriptRun?: (
545 input: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput,701 input: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput,
546 ) => Effect.Effect<void>;702 ) => Effect.Effect<void>;
547 /** Runs inside each `closeIdle` call. */703 /** Runs inside each `close` call. */
548 readonly onCloseIdle?: () => Effect.Effect<void>;704 readonly onCloseTerminals?: () => Effect.Effect<void>;
705 readonly terminals?: ReadonlyArray<TerminalSummary>;
706 readonly onStopUnused?: (threadId: ThreadId) => Effect.Effect<void, DevServerError>;
549 /** Threads `getThread` returns when a `thread.settled` event is handled. */707 /** Threads `getThread` returns when a `thread.settled` event is handled. */
550 readonly currentThreads?: ReadonlyArray<OrchestrationV2AppThread>;708 readonly currentThreads?: ReadonlyArray<OrchestrationV2AppThread>;
551}709}
...@@ -559,7 +717,13 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -559,7 +717,13 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
559 const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS);717 const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS);
560 const settingsChanges = yield* PubSub.unbounded<ContractServerSettings>();718 const settingsChanges = yield* PubSub.unbounded<ContractServerSettings>();
561 const mergedPullRequests = yield* PubSub.unbounded<PullRequestService.PullRequestMergeEvent>();719 const mergedPullRequests = yield* PubSub.unbounded<PullRequestService.PullRequestMergeEvent>();
562 const commands = yield* Ref.make<ReadonlyArray<AutoSettleCommand>>([]);720 const mergebotChanges = yield* PubSub.unbounded<{
721 readonly repository: string;
722 readonly number?: number;
723 }>();
724 const mergebotSubscribed = yield* Deferred.make<void>();
725 const providerDetailReads = yield* Ref.make(0);
726 const commands = yield* Ref.make<ReadonlyArray<SettlementCommand>>([]);
563 const branchCalls = yield* Ref.make<727 const branchCalls = yield* Ref.make<
564 ReadonlyArray<{ readonly cwd: string; readonly branch: string }>728 ReadonlyArray<{ readonly cwd: string; readonly branch: string }>
565 >([]);729 >([]);
...@@ -572,10 +736,17 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -572,10 +736,17 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
572 >([]);736 >([]);
573 const summaryRecovery = yield* Ref.make<ReadonlyArray<boolean | undefined>>([]);737 const summaryRecovery = yield* Ref.make<ReadonlyArray<boolean | undefined>>([]);
574 const invalidatedCwds = yield* Ref.make<ReadonlyArray<string>>([]);738 const invalidatedCwds = yield* Ref.make<ReadonlyArray<string>>([]);
575 const domainEvents = yield* PubSub.unbounded<OrchestrationV2DomainEvent>();739 const domainEvents = yield* PubSub.unbounded<OrchestrationV2StoredEvent>();
576 const closedIdle = yield* Queue.unbounded<{ readonly threadId: string }>();740 const previews = yield* PreviewManager.make.pipe(
741 Effect.provideService(Crypto.Crypto, testCrypto),
742 );
743 const closedTerminals = yield* Queue.unbounded<{ readonly threadId: string }>();
744 const archiveReads = yield* Queue.unbounded<void>();
745 const fullArchiveReadCount = yield* Ref.make(0);
746 const singleArchiveReads = yield* Ref.make<ReadonlyArray<ThreadId>>([]);
577 const scriptRuns =747 const scriptRuns =
578 yield* Queue.unbounded<ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput>();748 yield* Queue.unbounded<ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput>();
749 const devServerStops = yield* Ref.make<ReadonlyArray<ThreadId>>([]);
579750
580 const updateSettings = (patch: ServerSettingsPatch) =>751 const updateSettings = (patch: ServerSettingsPatch) =>
581 Effect.gen(function* () {752 Effect.gen(function* () {
...@@ -611,7 +782,12 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -611,7 +782,12 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
611 });782 });
612783
613 const dispatch: Orchestrator.OrchestratorV2Shape["dispatch"] = (command) => {784 const dispatch: Orchestrator.OrchestratorV2Shape["dispatch"] = (command) => {
614 if (command.type !== "thread.auto-settle") {785 if (
786 command.type !== "thread.auto-settle" &&
787 command.type !== "thread.archive" &&
788 command.type !== "thread.ci-failed" &&
789 command.type !== "thread.unsettle"
790 ) {
615 return Effect.die(new Error(`Unexpected command: ${command.type}`));791 return Effect.die(new Error(`Unexpected command: ${command.type}`));
616 }792 }
617 return Ref.update(commands, (recorded) => [...recorded, command]).pipe(793 return Ref.update(commands, (recorded) => [...recorded, command]).pipe(
...@@ -634,10 +810,74 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -634,10 +810,74 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
634 });810 });
635811
636 const layerDependencies = Layer.mergeAll(812 const layerDependencies = Layer.mergeAll(
813 Layer.mock(OrchestrationEventStore.OrchestrationEventStore)({
814 latestAgentSequence: () => Effect.succeed(1),
815 getThreadSettlementTime: (threadId) =>
816 Ref.get(snapshots).pipe(
817 Effect.map(
818 (snapshot) =>
819 options.settlementTimes?.get(threadId) ??
820 snapshot.threads.find((thread) => thread.id === threadId)?.settledAt ??
821 null,
822 ),
823 ),
824 }),
825 Layer.mock(ManagedWorkspaces.ManagedWorkspaces)({
826 isManagedThread:
827 options.managedLookup ??
828 ((threadId) => Effect.succeed(options.managedThreadIds?.includes(threadId) ?? false)),
829 }),
637 Layer.mock(ProjectStore.ProjectStoreV2)({830 Layer.mock(ProjectStore.ProjectStoreV2)({
638 listShells: () => Ref.get(snapshots).pipe(Effect.map((snapshot) => snapshot.projects)),831 listShells: () => Ref.get(snapshots).pipe(Effect.map((snapshot) => snapshot.projects)),
639 }),832 }),
640 Layer.mock(ProjectionStore.ProjectionStoreV2)({833 Layer.mock(ProjectionStore.ProjectionStoreV2)({
834 getThreadShell: (threadId) =>
835 Ref.update(singleArchiveReads, (reads) => [...reads, threadId]).pipe(
836 Effect.andThen(Queue.offer(archiveReads, undefined)),
837 Effect.andThen(Ref.get(snapshots)),
838 Effect.map(
839 (snapshot) =>
840 [...snapshot.threads, ...snapshot.archivedThreads].find(
841 (thread) => thread.id === threadId,
842 ) ?? null,
843 ),
844 ),
845 getShellSnapshot: (input) =>
846 Ref.update(fullArchiveReadCount, (count) => count + 1).pipe(
847 Effect.andThen(Queue.offer(archiveReads, undefined)),
848 Effect.andThen(Ref.get(snapshots)),
849 Effect.map((snapshot) => ({
850 ...snapshot,
851 threads: snapshot.threads.filter(
852 (thread) => !input?.settledOnly || thread.settledOverride === "settled",
853 ),
854 })),
855 ),
856 getTurnItem: (input) =>
857 Effect.succeed(
858 options.previousCiNotice || options.previousItemIds?.has(input.itemId)
859 ? {
860 ...input,
861 id: input.itemId,
862 type: "notification",
863 source: { kind: "system" },
864 outcome: "failed",
865 summary: "CI failed",
866 runId: null,
867 nodeId: null,
868 providerThreadId: null,
869 providerTurnId: null,
870 nativeItemRef: null,
871 parentItemId: null,
872 ordinal: 0,
873 status: "completed",
874 title: null,
875 startedAt: DateTime.makeUnsafe(NOW),
876 completedAt: DateTime.makeUnsafe(NOW),
877 updatedAt: DateTime.makeUnsafe(NOW),
878 }
879 : null,
880 ),
641 getSettlementCandidates: (threadId) =>881 getSettlementCandidates: (threadId) =>
642 Ref.update(candidateReads, (reads) => [...reads, threadId]).pipe(882 Ref.update(candidateReads, (reads) => [...reads, threadId]).pipe(
643 Effect.andThen(Ref.updateAndGet(snapshotReadCount, (count) => count + 1)),883 Effect.andThen(Ref.updateAndGet(snapshotReadCount, (count) => count + 1)),
...@@ -657,12 +897,25 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -657,12 +897,25 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
657 },897 },
658 }),898 }),
659 Layer.mock(Orchestrator.OrchestratorV2)({899 Layer.mock(Orchestrator.OrchestratorV2)({
660 streamDomainEvents: Stream.fromPubSub(domainEvents),900 streamStoredEventsFrom: () => Stream.fromPubSub(domainEvents),
661 dispatch,901 dispatch,
662 }),902 }),
663 Layer.mock(TerminalManager.TerminalManager)({903 Layer.mock(TerminalManager.TerminalManager)({
664 closeIdle: (input) =>904 close: (input) =>
665 Queue.offer(closedIdle, input).pipe(Effect.andThen(options.onCloseIdle?.() ?? Effect.void)),905 Queue.offer(closedTerminals, input).pipe(
906 Effect.andThen(options.onCloseTerminals?.() ?? Effect.void),
907 ),
908 list: ({ threadId }) =>
909 Effect.succeed(
910 options.terminals?.filter((terminal) => terminal.threadId === threadId) ?? [],
911 ),
912 }),
913 Layer.succeed(PreviewManager.PreviewManager, previews),
914 Layer.mock(DevServer.DevServer)({
915 stopUnused: (threadId) =>
916 Ref.update(devServerStops, (stops) => [...stops, threadId]).pipe(
917 Effect.andThen(options.onStopUnused?.(threadId) ?? Effect.void),
918 ),
666 }),919 }),
667 Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({920 Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({
668 runForThread: (input) =>921 runForThread: (input) =>
...@@ -676,11 +929,34 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -676,11 +929,34 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
676 invalidateStatus: (cwd) => Ref.update(invalidatedCwds, (cwds) => [...cwds, cwd]),929 invalidateStatus: (cwd) => Ref.update(invalidatedCwds, (cwds) => [...cwds, cwd]),
677 }),930 }),
678 Layer.mock(PullRequestService.PullRequestService)({931 Layer.mock(PullRequestService.PullRequestService)({
932 invalidate: () => Effect.void,
933 detail: (input, ...rest) =>
934 Ref.update(providerDetailReads, (count) => count + 1).pipe(
935 Effect.andThen(
936 options.pullRequestDetail?.(input, ...rest) ?? Effect.die("Unexpected detail read"),
937 ),
938 ),
679 summary: pullRequestSummary,939 summary: pullRequestSummary,
680 subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(940 subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
681 Effect.map((subscription) => Stream.fromSubscription(subscription)),941 Effect.map((subscription) => Stream.fromSubscription(subscription)),
682 ),942 ),
683 }),943 }),
944 Layer.mock(MergebotQueue.MergebotQueue)({
945 configuration: {
946 origin: "https://mergebot.example.test",
947 repositories: ["owner/repository"],
948 },
949 read: () => Effect.succeed(null),
950 readPullRequest: options.mergebotDetail ?? (() => Effect.succeed(null)),
951 findPullRequest: options.mergebotFind ?? (() => Effect.succeed(null)),
952 streamChanges: Stream.unwrap(
953 Effect.gen(function* () {
954 const subscription = yield* PubSub.subscribe(mergebotChanges);
955 yield* Deferred.succeed(mergebotSubscribed, undefined);
956 return Stream.fromSubscription(subscription);
957 }),
958 ),
959 }),
684 Layer.succeed(ServerSettings.ServerSettingsService, serverSettings),960 Layer.succeed(ServerSettings.ServerSettingsService, serverSettings),
685 Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)),961 Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)),
686 Layer.succeed(Crypto.Crypto, testCrypto),962 Layer.succeed(Crypto.Crypto, testCrypto),
...@@ -699,10 +975,23 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -699,10 +975,23 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
699 branchCalls,975 branchCalls,
700 summaryCalls,976 summaryCalls,
701 summaryRecovery,977 summaryRecovery,
978 providerDetailReads,
979 mergebotSubscribed,
980 publishMergebotChange: (number?: number) =>
981 PubSub.publish(mergebotChanges, {
982 repository: "owner/repository",
983 ...(number === undefined ? {} : { number }),
984 }),
702 invalidatedCwds,985 invalidatedCwds,
703 closedIdle,986 closedTerminals,
987 archiveReads,
988 fullArchiveReadCount,
989 singleArchiveReads,
704 scriptRuns,990 scriptRuns,
705 publishEvent: (event: OrchestrationV2DomainEvent) => PubSub.publish(domainEvents, event),991 devServerStops,
992 previews,
993 publishEvent: (event: OrchestrationV2DomainEvent, commandId: CommandId | null = null) =>
994 PubSub.publish(domainEvents, { sequence: 2, commandId, event }),
706 updateSettings,995 updateSettings,
707 publishMerge: PubSub.publish(mergedPullRequests, {996 publishMerge: PubSub.publish(mergedPullRequests, {
708 projectId: PROJECT_ID,997 projectId: PROJECT_ID,
...@@ -726,6 +1015,231 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* (...@@ -726,6 +1015,231 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* (
726});1015});
7271016
728describe("ThreadSettlementServiceV2 worker", () => {1017describe("ThreadSettlementServiceV2 worker", () => {
1018 it.effect.each([true, false])(
1019 "honors inactivity PR retention %s for canonical work links without cached snapshots",
1020 (keepPullRequests) =>
1021 Effect.scoped(
1022 Effect.gen(function* () {
1023 yield* TestClock.setTime(Date.parse(NOW));
1024 const thread = makeThread("inactive-canonical-work-pr", { pullRequests: [link(1)] });
1025 const fixture = yield* makeHarness({
1026 snapshot: makeSnapshot([thread]),
1027 settings: {
1028 ...DEFAULT_SERVER_SETTINGS,
1029 sidebarAutoSettleAfterDays: 3,
1030 sidebarAutoSettleKeepPullRequests: keepPullRequests,
1031 },
1032 });
1033
1034 yield* Effect.gen(function* () {
1035 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1036 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1037 expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual(
1038 keepPullRequests ? [] : [thread.id],
1039 );
1040 }).pipe(Effect.provide(fixture.layer));
1041 }),
1042 ),
1043 );
1044
1045 it.effect("uses only Mergebot for configured linked and branch pull requests", () =>
1046 Effect.scoped(
1047 Effect.gen(function* () {
1048 yield* TestClock.setTime(Date.parse(NOW));
1049 const workProject = {
1050 ...makeProject(),
1051 repositoryIdentity: {
1052 canonicalKey: "github.com/owner/repository",
1053 locator: {
1054 source: "git-remote" as const,
1055 remoteName: "origin",
1056 remoteUrl: "https://github.com/owner/repository",
1057 },
1058 },
1059 };
1060 const legacy = makeThread("legacy-work-pr", {
1061 linkedPullRequest: {
1062 projectId: PROJECT_ID,
1063 repository: "owner/repository",
1064 number: 1,
1065 url: link(1).url,
1066 },
1067 });
1068 const linked = makeThread("linked-work-pr", {
1069 pullRequests: [
1070 {
1071 ...link(1),
1072 snapshot: {
1073 state: "open",
1074 title: "Old snapshot",
1075 headBranch: "feature",
1076 baseBranch: "main",
1077 isDraft: false,
1078 updatedAt: NOW,
1079 syncedAt: NOW,
1080 },
1081 },
1082 ],
1083 });
1084 const branch = makeThread("branch-work-pr", { branch: "feature" });
1085 const reused = makeThread("reused-work-branch", {
1086 linkedPullRequest: legacy.linkedPullRequest,
1087 branch: "reused-feature",
1088 });
1089 const fixture = yield* makeHarness({
1090 settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: null },
1091 snapshot: makeSnapshot([legacy, linked, branch, reused], [workProject]),
1092 mergebotDetail: ({ number }) =>
1093 Effect.succeed(mergebotDetail(number, { lifecycle: "merged", mergedAt: NOW })),
1094 mergebotFind: ({ headBranch }) =>
1095 Effect.succeed(
1096 mergebotDetail(1, {
1097 lifecycle: headBranch === "reused-feature" ? "open" : "merged",
1098 mergedAt: NOW,
1099 }),
1100 ),
1101 });
1102 yield* Effect.gen(function* () {
1103 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1104 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1105 expect(
1106 (yield* Ref.get(fixture.commands)).map((command) => command.threadId).toSorted(),
1107 ).toEqual([legacy.id, linked.id, branch.id].toSorted());
1108 expect(yield* Ref.get(fixture.branchCalls)).toEqual([]);
1109 expect(yield* Ref.get(fixture.summaryCalls)).toEqual([]);
1110 expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0);
1111 }).pipe(Effect.provide(fixture.layer));
1112 }),
1113 ),
1114 );
1115
1116 it.effect(
1117 "ignores cached work PR snapshots and unknown merge times while inactivity stays independent",
1118 () =>
1119 Effect.scoped(
1120 Effect.gen(function* () {
1121 yield* TestClock.setTime(Date.parse(NOW));
1122 const thread = makeThread("unknown-work-pr", {
1123 pullRequests: [
1124 {
1125 ...link(1),
1126 snapshot: {
1127 state: "merged",
1128 title: "Cached merge",
1129 headBranch: "feature",
1130 baseBranch: "main",
1131 isDraft: false,
1132 updatedAt: NOW,
1133 syncedAt: NOW,
1134 mergedAt: NOW,
1135 },
1136 },
1137 ],
1138 });
1139 for (const state of ["disconnected", "unknown-time", "inactive"] as const) {
1140 const fixture = yield* makeHarness({
1141 settings: {
1142 ...DEFAULT_SERVER_SETTINGS,
1143 sidebarAutoSettleAfterDays: state === "inactive" ? 1 : null,
1144 },
1145 snapshot: makeSnapshot([thread]),
1146 mergebotDetail: () =>
1147 Effect.succeed(
1148 state === "unknown-time"
1149 ? mergebotDetail(1, { lifecycle: "merged", updatedAt: NOW, mergedAt: null })
1150 : null,
1151 ),
1152 });
1153 yield* Effect.gen(function* () {
1154 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1155 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1156 expect((yield* Ref.get(fixture.commands)).length).toBe(state === "inactive" ? 1 : 0);
1157 expect(yield* Ref.get(fixture.branchCalls)).toEqual([]);
1158 expect(yield* Ref.get(fixture.summaryCalls)).toEqual([]);
1159 expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0);
1160 }).pipe(Effect.provide(fixture.layer));
1161 }
1162 }),
1163 ),
1164 );
1165
1166 it.effect("rechecks open resources after waiting for a merged pull request", () =>
1167 Effect.scoped(
1168 Effect.gen(function* () {
1169 yield* TestClock.setTime(Date.parse(NOW));
1170 const lookupStarted = yield* Deferred.make<void>();
1171 const lookupResult = yield* Deferred.make<GitManager.GitBranchPullRequest>();
1172 const thread = makeThread("open-during-lookup", { branch: "feature" });
1173 const fixture = yield* makeHarness({
1174 snapshot: makeSnapshot([thread]),
1175 settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: null },
1176 branchPullRequest: () =>
1177 Deferred.succeed(lookupStarted, undefined).pipe(
1178 Effect.andThen(Deferred.await(lookupResult)),
1179 ),
1180 });
1181
1182 yield* Effect.gen(function* () {
1183 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1184 const startup = yield* startHarness(
1185 service,
1186 fixture.activation,
1187 fixture.snapshotReads,
1188 ).pipe(Effect.forkScoped);
1189 yield* Deferred.await(lookupStarted);
1190 const tab = yield* fixture.previews.open({ threadId: thread.id });
1191 yield* Deferred.succeed(lookupResult, makeBranchPullRequest("merged"));
1192 yield* Fiber.join(startup);
1193 expect(yield* Ref.get(fixture.commands)).toEqual([]);
1194 expect((yield* fixture.previews.list({ threadId: thread.id })).sessions).toEqual([tab]);
1195 }).pipe(Effect.provide(fixture.layer));
1196 }),
1197 ),
1198 );
1199
1200 it.effect("keeps unattended chats with terminals, browser tabs, or commands active", () =>
1201 Effect.scoped(
1202 Effect.gen(function* () {
1203 yield* TestClock.setTime(Date.parse(NOW));
1204 const runningTerminal = makeThread("running-terminal");
1205 const idleTerminal = makeThread("idle-terminal");
1206 const exitedTerminal = makeThread("exited-terminal");
1207 const browser = makeThread("browser-tab");
1208 const background = makeThread("background-command", {
1209 pendingBackgroundTasks: [{ taskId: "command", kind: "command" }],
1210 });
1211 const quiet = makeThread("quiet-chat");
1212 const fixture = yield* makeHarness({
1213 snapshot: makeSnapshot([
1214 runningTerminal,
1215 idleTerminal,
1216 exitedTerminal,
1217 browser,
1218 background,
1219 quiet,
1220 ]),
1221 settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 1 },
1222 terminals: [
1223 terminal(runningTerminal.id),
1224 { ...terminal(idleTerminal.id), hasRunningSubprocess: false },
1225 { ...terminal(exitedTerminal.id), status: "exited", pid: null },
1226 ],
1227 });
1228 yield* fixture.previews.open({ threadId: browser.id });
1229
1230 yield* Effect.gen(function* () {
1231 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1232 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1233 expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([
1234 quiet.id,
1235 ]);
1236 expect((yield* fixture.previews.list({ threadId: browser.id })).sessions).toHaveLength(1);
1237 expect(yield* Queue.size(fixture.closedTerminals)).toBe(0);
1238 }).pipe(Effect.provide(fixture.layer));
1239 }),
1240 ),
1241 );
1242
729 it.effect("settles a merged pull request stored only in the thread links", () =>1243 it.effect("settles a merged pull request stored only in the thread links", () =>
730 Effect.scoped(1244 Effect.scoped(
731 Effect.gen(function* () {1245 Effect.gen(function* () {
...@@ -769,6 +1283,54 @@ describe("ThreadSettlementServiceV2 worker", () => {...@@ -769,6 +1283,54 @@ describe("ThreadSettlementServiceV2 worker", () => {
769 ),1283 ),
770 );1284 );
7711285
1286 it.effect("reevaluates a merged link immediately after its compact update event", () =>
1287 Effect.scoped(
1288 Effect.gen(function* () {
1289 yield* TestClock.setTime(Date.parse(NOW));
1290 const mergedLink = {
1291 ...link(42),
1292 host: "example.test",
1293 url: "https://example.test/owner/repository/pull/42",
1294 snapshot: {
1295 state: "merged" as const,
1296 title: "Pull request",
1297 headBranch: "feature",
1298 baseBranch: "main",
1299 isDraft: false,
1300 updatedAt: NOW,
1301 syncedAt: NOW,
1302 mergedAt: NOW,
1303 },
1304 };
1305 const thread = makeThread("compact-merged-link", { pullRequests: [mergedLink] });
1306 const settled = yield* Deferred.make<void>();
1307 const fixture = yield* makeHarness({
1308 snapshot: makeSnapshot([]),
1309 settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: null },
1310 onDispatch: () => Deferred.succeed(settled, undefined),
1311 });
1312
1313 yield* Effect.gen(function* () {
1314 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1315 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1316 yield* Ref.set(fixture.snapshots, makeSnapshot([thread]));
1317 yield* fixture.publishEvent({
1318 id: EventId.make("compact-merged-link"),
1319 type: "thread.pull-request-link-updated",
1320 threadId: thread.id,
1321 occurredAt: DateTime.makeUnsafe(NOW),
1322 payload: mergedLink,
1323 });
1324 yield* Deferred.await(settled);
1325 yield* service.drain;
1326 expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([
1327 thread.id,
1328 ]);
1329 }).pipe(Effect.provide(fixture.layer));
1330 }),
1331 ),
1332 );
1333
772 it.effect("settles only the project opted in while environment settlement is disabled", () =>1334 it.effect("settles only the project opted in while environment settlement is disabled", () =>
773 Effect.scoped(1335 Effect.scoped(
774 Effect.gen(function* () {1336 Effect.gen(function* () {
...@@ -814,6 +1376,8 @@ describe("ThreadSettlementServiceV2 worker", () => {...@@ -814,6 +1376,8 @@ describe("ThreadSettlementServiceV2 worker", () => {
814 yield* startHarness(service, fixture.activation, fixture.snapshotReads);1376 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
815 const commands = yield* Ref.get(fixture.commands);1377 const commands = yield* Ref.get(fixture.commands);
816 expect(commands).toHaveLength(1);1378 expect(commands).toHaveLength(1);
1379 assert.equal(commands[0]?.type, "thread.auto-settle");
1380 if (commands[0]?.type !== "thread.auto-settle") return;
817 expect(commands[0]?.settledAt).toEqual(thread.latestRunCompletedAt);1381 expect(commands[0]?.settledAt).toEqual(thread.latestRunCompletedAt);
818 expect(commands[0]?.snapshotAt).toEqual(thread.updatedAt);1382 expect(commands[0]?.snapshotAt).toEqual(thread.updatedAt);
819 }).pipe(Effect.provide(fixture.layer));1383 }).pipe(Effect.provide(fixture.layer));
...@@ -1077,6 +1641,185 @@ describe("ThreadSettlementServiceV2 worker", () => {...@@ -1077,6 +1641,185 @@ describe("ThreadSettlementServiceV2 worker", () => {
1077 );1641 );
1078});1642});
10791643
1644describe("ThreadSettlementServiceV2 development servers", () => {
1645 const settings = {
1646 ...DEFAULT_SERVER_SETTINGS,
1647 sidebarAutoSettleOnMerge: false,
1648 sidebarAutoSettleAfterDays: null,
1649 };
1650 const settled = (id: string, overrides: Partial<SettlementShell> = {}) =>
1651 makeThread(id, {
1652 settledOverride: "settled",
1653 settledAt: DateTime.makeUnsafe(NOW),
1654 ...overrides,
1655 });
1656 const start = Effect.fn("startDevServerCleanupHarness")(function* (
1657 fixture: Effect.Success<ReturnType<typeof makeHarness>>,
1658 ) {
1659 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1660 yield* service.start();
1661 yield* Deferred.succeed(fixture.activation, undefined);
1662 yield* Queue.take(fixture.archiveReads);
1663 yield* service.drain;
1664 return service;
1665 });
1666
1667 it.effect(
1668 "reconciles settled and archived servers on startup with auto-settlement disabled",
1669 () =>
1670 Effect.scoped(
1671 Effect.gen(function* () {
1672 yield* TestClock.setTime(Date.parse(NOW));
1673 const parked = settled("parked");
1674 const archived = makeThread("archived", { archivedAt: DateTime.makeUnsafe(NOW) });
1675 const fixture = yield* makeHarness({
1676 settings,
1677 snapshot: {
1678 ...makeSnapshot([parked, makeThread("active")]),
1679 archivedThreads: [archived],
1680 },
1681 managedThreadIds: [parked.id],
1682 });
1683 yield* start(fixture).pipe(Effect.provide(fixture.layer));
1684 assert.deepStrictEqual((yield* Ref.get(fixture.devServerStops)).toSorted(), [
1685 archived.id,
1686 parked.id,
1687 ]);
1688 assert.equal(yield* Queue.size(fixture.scriptRuns), 0);
1689 }),
1690 ),
1691 );
1692
1693 it.effect(
1694 "settling retains a managed workspace while stopping its separate development server",
1695 () =>
1696 Effect.scoped(
1697 Effect.gen(function* () {
1698 yield* TestClock.setTime(Date.parse(NOW));
1699 const thread = settled("managed", { worktreePath: null });
1700 const fixture = yield* makeHarness({
1701 settings,
1702 snapshot: makeSnapshot([]),
1703 currentThreads: [thread],
1704 managedThreadIds: [thread.id],
1705 });
1706 yield* Effect.gen(function* () {
1707 const service = yield* start(fixture);
1708 yield* Ref.set(fixture.snapshots, makeSnapshot([thread]));
1709 yield* fixture.publishEvent({
1710 type: "thread.settled",
1711 id: EventId.make("settled:managed"),
1712 threadId: thread.id,
1713 occurredAt: DateTime.makeUnsafe(NOW),
1714 payload: thread,
1715 });
1716 yield* Queue.take(fixture.archiveReads);
1717 yield* service.drain;
1718 assert.deepStrictEqual(yield* Ref.get(fixture.devServerStops), [thread.id]);
1719 assert.equal(yield* Queue.size(fixture.scriptRuns), 0);
1720 assert.equal(yield* Queue.size(fixture.closedTerminals), 1);
1721 }).pipe(Effect.provide(fixture.layer));
1722 }),
1723 ),
1724 );
1725
1726 it.effect("retries a failed shutdown on the next minute without blocking healthy threads", () =>
1727 Effect.scoped(
1728 Effect.gen(function* () {
1729 yield* TestClock.setTime(Date.parse(NOW));
1730 const failed = settled("failed-stop");
1731 const healthy = settled("healthy-stop");
1732 const attempts = yield* Ref.make(0);
1733 const fixture = yield* makeHarness({
1734 settings,
1735 snapshot: makeSnapshot([failed, healthy]),
1736 onStopUnused: (threadId) =>
1737 threadId !== failed.id
1738 ? Effect.void
1739 : Ref.updateAndGet(attempts, (count) => count + 1).pipe(
1740 Effect.flatMap((count) =>
1741 count === 1
1742 ? Effect.fail(new DevServerError({ reason: "status" }))
1743 : Effect.void,
1744 ),
1745 ),
1746 });
1747 yield* Effect.gen(function* () {
1748 const service = yield* start(fixture);
1749 assert.deepStrictEqual((yield* Ref.get(fixture.devServerStops)).toSorted(), [
1750 failed.id,
1751 healthy.id,
1752 ]);
1753 yield* TestClock.adjust("1 minute");
1754 yield* Queue.take(fixture.archiveReads);
1755 yield* service.drain;
1756 assert.equal(yield* Ref.get(attempts), 2);
1757 assert.equal((yield* Ref.get(fixture.devServerStops)).length, 4);
1758 assert.equal(yield* Queue.size(fixture.scriptRuns), 0);
1759 }).pipe(Effect.provide(fixture.layer));
1760 }),
1761 ),
1762 );
1763
1764 it.effect("committed archival stops an unsettled thread's server", () =>
1765 Effect.scoped(
1766 Effect.gen(function* () {
1767 yield* TestClock.setTime(Date.parse(NOW));
1768 const thread = makeThread("archiving", { archivedAt: DateTime.makeUnsafe(NOW) });
1769 const fixture = yield* makeHarness({ settings, snapshot: makeSnapshot([]) });
1770 yield* Effect.gen(function* () {
1771 const service = yield* start(fixture);
1772 yield* Ref.set(fixture.snapshots, {
1773 ...makeSnapshot([]),
1774 archivedThreads: [thread],
1775 });
1776 yield* fixture.publishEvent({
1777 type: "thread.archived",
1778 id: EventId.make("archived:archiving"),
1779 threadId: thread.id,
1780 occurredAt: DateTime.makeUnsafe(NOW),
1781 payload: thread,
1782 });
1783 yield* Queue.take(fixture.archiveReads);
1784 yield* service.drain;
1785 assert.deepStrictEqual(yield* Ref.get(fixture.devServerStops), [thread.id]);
1786 assert.equal(yield* Queue.size(fixture.scriptRuns), 0);
1787 }).pipe(Effect.provide(fixture.layer));
1788 }),
1789 ),
1790 );
1791
1792 it.effect("a settlement event already overtaken by revival does not stop its server", () =>
1793 Effect.scoped(
1794 Effect.gen(function* () {
1795 yield* TestClock.setTime(Date.parse(NOW));
1796 const old = settled("revived");
1797 const active = makeThread("revived", { settledOverride: "active", status: "running" });
1798 const fixture = yield* makeHarness({
1799 settings,
1800 snapshot: makeSnapshot([]),
1801 currentThreads: [active],
1802 });
1803 yield* Effect.gen(function* () {
1804 const service = yield* start(fixture);
1805 yield* Ref.set(fixture.snapshots, makeSnapshot([active]));
1806 yield* fixture.publishEvent({
1807 type: "thread.settled",
1808 id: EventId.make("settled:revived"),
1809 threadId: old.id,
1810 occurredAt: DateTime.makeUnsafe(NOW),
1811 payload: old,
1812 });
1813 yield* Queue.take(fixture.archiveReads);
1814 yield* service.drain;
1815 assert.deepStrictEqual(yield* Ref.get(fixture.devServerStops), []);
1816 assert.equal(yield* Queue.size(fixture.closedTerminals), 0);
1817 }).pipe(Effect.provide(fixture.layer));
1818 }),
1819 ),
1820 );
1821});
1822
1080describe("ThreadSettlementServiceV2 terminals", () => {1823describe("ThreadSettlementServiceV2 terminals", () => {
1081 const appThread = (1824 const appThread = (
1082 id: string,1825 id: string,
...@@ -1116,7 +1859,7 @@ describe("ThreadSettlementServiceV2 terminals", () => {...@@ -1116,7 +1859,7 @@ describe("ThreadSettlementServiceV2 terminals", () => {
1116 payload: thread,1859 payload: thread,
1117 });1860 });
11181861
1119 it.effect("closes the thread's idle terminals when it settles", () =>1862 it.effect("closes all of the thread's terminals and browser tabs when it settles", () =>
1120 Effect.scoped(1863 Effect.scoped(
1121 Effect.gen(function* () {1864 Effect.gen(function* () {
1122 const thread = appThread("settled-thread", "settled");1865 const thread = appThread("settled-thread", "settled");
...@@ -1124,12 +1867,88 @@ describe("ThreadSettlementServiceV2 terminals", () => {...@@ -1124,12 +1867,88 @@ describe("ThreadSettlementServiceV2 terminals", () => {
1124 snapshot: makeSnapshot([]),1867 snapshot: makeSnapshot([]),
1125 currentThreads: [thread],1868 currentThreads: [thread],
1126 });1869 });
1870 yield* fixture.previews.open({ threadId: thread.id });
1871 yield* fixture.previews.open({ threadId: thread.id, runtime: "server" });
1872 const peerId = ThreadId.make("other-chat");
1873 const peerTab = yield* fixture.previews.open({ threadId: peerId });
1874 const previewEvents = yield* fixture.previews.subscribeEvents;
11271875
1128 yield* Effect.gen(function* () {1876 yield* Effect.gen(function* () {
1129 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;1877 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1130 yield* startHarness(service, fixture.activation, fixture.snapshotReads);1878 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1131 yield* fixture.publishEvent(settledEvent(thread));1879 yield* fixture.publishEvent(settledEvent(thread));
1132 assert.deepStrictEqual(yield* Queue.take(fixture.closedIdle), { threadId: thread.id });1880 assert.deepStrictEqual(yield* Queue.take(fixture.closedTerminals), {
1881 threadId: thread.id,
1882 });
1883 expect((yield* PubSub.take(previewEvents)).type).toBe("closed");
1884 expect((yield* PubSub.take(previewEvents)).type).toBe("closed");
1885 expect((yield* fixture.previews.list({ threadId: thread.id })).sessions).toEqual([]);
1886 expect((yield* fixture.previews.list({ threadId: peerId })).sessions).toEqual([peerTab]);
1887 const active = appThread("settled-thread", "active");
1888 yield* fixture.publishEvent({
1889 type: "thread.unsettled",
1890 id: EventId.make("event:unsettled:managed"),
1891 threadId: active.id,
1892 occurredAt: DateTime.makeUnsafe(NOW),
1893 payload: active,
1894 });
1895 }).pipe(Effect.provide(fixture.layer));
1896 }),
1897 ),
1898 );
1899
1900 it.effect("preserves resources opened during an automatic settlement", () =>
1901 Effect.scoped(
1902 Effect.gen(function* () {
1903 const thread = appThread("automatic-thread", "settled", "/worktrees/automatic");
1904 const marker = appThread("marker-thread", "settled", "/worktrees/marker");
1905 const fixture = yield* makeHarness({
1906 snapshot: makeSnapshot([]),
1907 currentThreads: [thread, marker],
1908 terminals: [terminal(thread.id)],
1909 existingWorktreePaths: ["/worktrees/automatic", "/worktrees/marker"],
1910 });
1911 const tab = yield* fixture.previews.open({ threadId: thread.id });
1912
1913 yield* Effect.gen(function* () {
1914 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1915 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1916 yield* fixture.publishEvent(
1917 settledEvent(thread),
1918 CommandId.make("server:auto-settle:automatic-thread:1"),
1919 );
1920 yield* fixture.publishEvent(settledEvent(marker));
1921 expect((yield* Queue.take(fixture.scriptRuns)).threadId).toBe(marker.id);
1922 expect(yield* Queue.take(fixture.closedTerminals)).toEqual({ threadId: marker.id });
1923 expect(yield* Queue.size(fixture.closedTerminals)).toBe(0);
1924 expect((yield* fixture.previews.list({ threadId: thread.id })).sessions).toEqual([tab]);
1925 expect(yield* Ref.get(fixture.commands)).toMatchObject([
1926 { type: "thread.unsettle", threadId: thread.id, snapshotSequence: 2 },
1927 ]);
1928 }).pipe(Effect.provide(fixture.layer));
1929 }),
1930 ),
1931 );
1932
1933 it.effect("ignores a stale settle event after the chat was revived and settled again", () =>
1934 Effect.scoped(
1935 Effect.gen(function* () {
1936 const previous = appThread("resettled-thread", "settled");
1937 const current = { ...previous, settledAt: DateTime.makeUnsafe(Date.parse(NOW) + 1) };
1938 const marker = appThread("marker-thread", "settled");
1939 const fixture = yield* makeHarness({
1940 snapshot: makeSnapshot([]),
1941 currentThreads: [current, marker],
1942 });
1943 const tab = yield* fixture.previews.open({ threadId: current.id });
1944
1945 yield* Effect.gen(function* () {
1946 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1947 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1948 yield* fixture.publishEvent(settledEvent(previous));
1949 yield* fixture.publishEvent(settledEvent(marker));
1950 expect(yield* Queue.take(fixture.closedTerminals)).toEqual({ threadId: marker.id });
1951 expect((yield* fixture.previews.list({ threadId: current.id })).sessions).toEqual([tab]);
1133 }).pipe(Effect.provide(fixture.layer));1952 }).pipe(Effect.provide(fixture.layer));
1134 }),1953 }),
1135 ),1954 ),
...@@ -1153,7 +1972,9 @@ describe("ThreadSettlementServiceV2 terminals", () => {...@@ -1153,7 +1972,9 @@ describe("ThreadSettlementServiceV2 terminals", () => {
1153 // Events run in order, so the first close belongs to the later1972 // Events run in order, so the first close belongs to the later
1154 // event only if the re-engaged thread was skipped.1973 // event only if the re-engaged thread was skipped.
1155 yield* fixture.publishEvent(settledEvent(marker));1974 yield* fixture.publishEvent(settledEvent(marker));
1156 assert.deepStrictEqual(yield* Queue.take(fixture.closedIdle), { threadId: marker.id });1975 assert.deepStrictEqual(yield* Queue.take(fixture.closedTerminals), {
1976 threadId: marker.id,
1977 });
1157 }).pipe(Effect.provide(fixture.layer));1978 }).pipe(Effect.provide(fixture.layer));
1158 }),1979 }),
1159 ),1980 ),
...@@ -1188,32 +2009,37 @@ describe("ThreadSettlementServiceV2 terminals", () => {...@@ -1188,32 +2009,37 @@ describe("ThreadSettlementServiceV2 terminals", () => {
1188 ),2009 ),
1189 );2010 );
11902011
1191 it.effect("skips the settle script for a thread re-engaged while its shells close", () =>2012 it.effect(
1192 Effect.scoped(2013 "keeps browser tabs and skips the settle script when revived while terminals close",
1193 Effect.gen(function* () {2014 () =>
1194 const settled = appThread("reengaging-thread", "settled", "/worktrees/reengaging");2015 Effect.scoped(
1195 const marker = appThread("marker-thread", "settled", "/worktrees/marker");2016 Effect.gen(function* () {
1196 const currentThreads = [settled, marker];2017 const settled = appThread("reengaging-thread", "settled", "/worktrees/reengaging");
1197 const fixture = yield* makeHarness({2018 const marker = appThread("marker-thread", "settled", "/worktrees/marker");
1198 snapshot: makeSnapshot([]),2019 const currentThreads = [settled, marker];
1199 currentThreads,2020 const fixture = yield* makeHarness({
1200 existingWorktreePaths: ["/worktrees/reengaging", "/worktrees/marker"],2021 snapshot: makeSnapshot([]),
1201 // The user sends a message while the first close checks processes.2022 currentThreads,
1202 onCloseIdle: () =>2023 existingWorktreePaths: ["/worktrees/reengaging", "/worktrees/marker"],
1203 Effect.sync(() => {2024 onCloseTerminals: () =>
1204 currentThreads[0] = { ...settled, settledOverride: "active" };2025 Effect.sync(() => {
1205 }),2026 currentThreads[0] = { ...settled, settledOverride: "active" };
1206 });2027 }),
2028 });
2029 const tab = yield* fixture.previews.open({ threadId: settled.id });
12072030
1208 yield* Effect.gen(function* () {2031 yield* Effect.gen(function* () {
1209 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;2032 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1210 yield* startHarness(service, fixture.activation, fixture.snapshotReads);2033 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1211 yield* fixture.publishEvent(settledEvent(settled));2034 yield* fixture.publishEvent(settledEvent(settled));
1212 yield* fixture.publishEvent(settledEvent(marker));2035 yield* fixture.publishEvent(settledEvent(marker));
1213 assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, marker.id);2036 assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, marker.id);
1214 }).pipe(Effect.provide(fixture.layer));2037 expect((yield* fixture.previews.list({ threadId: settled.id })).sessions).toEqual([
1215 }),2038 tab,
1216 ),2039 ]);
2040 }).pipe(Effect.provide(fixture.layer));
2041 }),
2042 ),
1217 );2043 );
12182044
1219 it.effect("runs the settle script once when a settlement is re-emitted", () =>2045 it.effect("runs the settle script once when a settlement is re-emitted", () =>
...@@ -1327,3 +2153,408 @@ describe("isSnoozed", () => {...@@ -1327,3 +2153,408 @@ describe("isSnoozed", () => {
1327 ).toBe(false);2153 ).toBe(false);
1328 });2154 });
1329});2155});
2156
2157describe("settled thread archival", () => {
2158 const settings = {
2159 ...DEFAULT_SERVER_SETTINGS,
2160 sidebarAutoSettleOnMerge: false,
2161 sidebarAutoSettleAfterDays: null,
2162 };
2163
2164 const start = Effect.fn("startArchiveHarness")(function* (
2165 fixture: Effect.Success<ReturnType<typeof makeHarness>>,
2166 ) {
2167 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
2168 yield* service.start();
2169 yield* Deferred.succeed(fixture.activation, undefined);
2170 yield* Queue.take(fixture.archiveReads);
2171 yield* service.drain;
2172 });
2173
2174 it.effect(
2175 "archives from a live Mergebot merge update without a minute timer or provider lookup",
2176 () =>
2177 Effect.scoped(
2178 Effect.gen(function* () {
2179 yield* TestClock.setTime(Date.parse(NOW));
2180 const thread = makeThread("live-merge", {
2181 settledOverride: "settled",
2182 settledAt: DateTime.makeUnsafe(NOW),
2183 pullRequests: [link(1)],
2184 });
2185 const detail = yield* Ref.make<MergebotPullRequestDetails | null>(null);
2186 const archived = yield* Deferred.make<void>();
2187 const fixture = yield* makeHarness({
2188 settings,
2189 snapshot: makeSnapshot([thread]),
2190 mergebotDetail: () => Ref.get(detail),
2191 onDispatch: (command) =>
2192 command.type === "thread.archive"
2193 ? Deferred.succeed(archived, undefined).pipe(Effect.asVoid)
2194 : Effect.void,
2195 });
2196 yield* Effect.gen(function* () {
2197 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
2198 yield* start(fixture);
2199 yield* Deferred.await(fixture.mergebotSubscribed);
2200 expect(yield* Ref.get(fixture.commands)).toEqual([]);
2201 yield* Ref.set(detail, mergebotDetail(1, { lifecycle: "merged", mergedAt: NOW }));
2202 yield* fixture.publishMergebotChange(1);
2203 yield* TestClock.adjust("100 millis");
2204 yield* Deferred.await(archived);
2205 yield* service.drain;
2206 expect((yield* Ref.get(fixture.commands)).map((command) => command.type)).toEqual([
2207 "thread.archive",
2208 ]);
2209 expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0);
2210 }).pipe(Effect.provide(fixture.layer));
2211 }),
2212 ),
2213 );
2214
2215 it.effect("Saturday archival stays available while Mergebot is disconnected", () =>
2216 Effect.scoped(
2217 Effect.gen(function* () {
2218 yield* TestClock.setTime(Date.parse(NOW));
2219 const boundary = ThreadSettlementService.lastSaturdayArchiveBoundary(Date.parse(NOW));
2220 const fixture = yield* makeHarness({
2221 settings,
2222 snapshot: makeSnapshot([
2223 makeThread("offline-saturday", {
2224 settledOverride: "settled",
2225 settledAt: DateTime.makeUnsafe(boundary),
2226 pullRequests: [link(1)],
2227 }),
2228 ]),
2229 mergebotDetail: () => Effect.succeed(null),
2230 });
2231 yield* start(fixture).pipe(Effect.provide(fixture.layer));
2232 expect((yield* Ref.get(fixture.commands)).map((command) => command.type)).toEqual([
2233 "thread.archive",
2234 ]);
2235 expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0);
2236 }),
2237 ),
2238 );
2239
2240 it("uses Saturday at nine in the server timezone, including a DST week", () => {
2241 const local = DateTime.zoneMakeLocal();
2242 const saturday = DateTime.makeZonedUnsafe(
2243 { year: 2026, month: 11, day: 7, hour: 9 },
2244 { timeZone: local, adjustForTimeZone: true },
2245 );
2246 const friday = DateTime.subtract(saturday, { days: 1 });
2247 expect(
2248 ThreadSettlementService.lastSaturdayArchiveBoundary(DateTime.toEpochMillis(saturday)),
2249 ).toBe(DateTime.toEpochMillis(saturday));
2250 expect(
2251 ThreadSettlementService.lastSaturdayArchiveBoundary(DateTime.toEpochMillis(friday)),
2252 ).toBe(DateTime.toEpochMillis(DateTime.subtract(saturday, { weeks: 1 })));
2253 expect(
2254 ThreadSettlementService.lastSaturdayArchiveBoundary(
2255 DateTime.toEpochMillis(DateTime.subtract(saturday, { milliseconds: 1 })),
2256 ),
2257 ).toBe(DateTime.toEpochMillis(DateTime.subtract(saturday, { weeks: 1 })));
2258 });
2259
2260 it.effect(
2261 "catches up after restart, excluding fresh settlements and busy threads with auto-settlement disabled",
2262 () =>
2263 Effect.scoped(
2264 Effect.gen(function* () {
2265 const nowMs = Date.parse(NOW);
2266 yield* TestClock.setTime(nowMs);
2267 const boundary = ThreadSettlementService.lastSaturdayArchiveBoundary(nowMs);
2268 const fixture = yield* makeHarness({
2269 settings,
2270 snapshot: makeSnapshot([
2271 makeThread("old", {
2272 settledOverride: "settled",
2273 settledAt: DateTime.makeUnsafe(boundary),
2274 }),
2275 makeThread("fresh", {
2276 settledOverride: "settled",
2277 settledAt: DateTime.makeUnsafe(boundary + 1),
2278 }),
2279 makeThread("busy", {
2280 settledOverride: "settled",
2281 settledAt: DateTime.makeUnsafe(boundary),
2282 activeRunId: RunId.make("run"),
2283 status: "running",
2284 }),
2285 makeThread("queued", {
2286 settledOverride: "settled",
2287 settledAt: DateTime.makeUnsafe(boundary),
2288 latestUserMessageAt: DateTime.makeUnsafe(nowMs),
2289 }),
2290 makeThread("active", { settledOverride: "active" }),
2291 makeThread("backdated", {
2292 settledOverride: "settled",
2293 settledAt: DateTime.makeUnsafe(boundary - DAY_MS),
2294 }),
2295 ]),
2296 settlementTimes: new Map([[ThreadId.make("backdated"), DateTime.makeUnsafe(nowMs)]]),
2297 });
2298 yield* start(fixture).pipe(Effect.provide(fixture.layer));
2299 assert.deepStrictEqual(
2300 (yield* Ref.get(fixture.commands)).map((command) => [command.type, command.threadId]),
2301 [["thread.archive", ThreadId.make("old")]],
2302 );
2303 }),
2304 ),
2305 );
2306
2307 it.effect(
2308 "archives a settled managed thread only when every visible PR merged, reading shared PRs once",
2309 () =>
2310 Effect.scoped(
2311 Effect.gen(function* () {
2312 yield* TestClock.setTime(Date.parse(NOW));
2313 const reads = yield* Ref.make<ReadonlyArray<number>>([]);
2314 const threads = ["merged-a", "merged-b", "stack-open"].map((id) =>
2315 makeThread(id, {
2316 settledOverride: "settled",
2317 settledAt: DateTime.makeUnsafe(NOW),
2318 pullRequests: id === "stack-open" ? [link(1), link(2)] : [link(1)],
2319 }),
2320 );
2321 const fixture = yield* makeHarness({
2322 settings,
2323 snapshot: makeSnapshot(threads),
2324 managedThreadIds: threads.map((thread) => thread.id),
2325 mergebotDetail: (reference) =>
2326 Ref.update(reads, (values) => [...values, reference.number]).pipe(
2327 Effect.as(
2328 mergebotDetail(reference.number, {
2329 lifecycle: reference.number === 1 ? "merged" : "open",
2330 }),
2331 ),
2332 ),
2333 });
2334 yield* start(fixture).pipe(Effect.provide(fixture.layer));
2335 assert.deepStrictEqual(
2336 (yield* Ref.get(fixture.commands)).map((command) => command.threadId).toSorted(),
2337 [ThreadId.make("merged-a"), ThreadId.make("merged-b")],
2338 );
2339 assert.deepStrictEqual((yield* Ref.get(reads)).toSorted(), [1, 2]);
2340 }),
2341 ),
2342 );
2343
2344 it.effect("a managed lookup failure leaves healthy archival and auto-settlement running", () =>
2345 Effect.scoped(
2346 Effect.gen(function* () {
2347 const nowMs = Date.parse(NOW);
2348 yield* TestClock.setTime(nowMs);
2349 const boundary = ThreadSettlementService.lastSaturdayArchiveBoundary(nowMs);
2350 const failed = makeThread("failed-lookup", {
2351 settledOverride: "settled",
2352 settledAt: DateTime.makeUnsafe(boundary),
2353 pullRequests: [{ ...link(1), repository: "unconfigured/repository" }],
2354 });
2355 const fixture = yield* makeHarness({
2356 settings: { ...settings, sidebarAutoSettleAfterDays: 1 },
2357 snapshot: makeSnapshot([
2358 failed,
2359 makeThread("plain-archive", {
2360 settledOverride: "settled",
2361 settledAt: DateTime.makeUnsafe(boundary),
2362 }),
2363 makeThread("inactive", {
2364 latestUserMessageAt: DateTime.makeUnsafe(nowMs - 30 * DAY_MS),
2365 }),
2366 ]),
2367 managedLookup: () => Effect.fail(new ManagedWorkspaceError({ reason: "workspace" })),
2368 });
2369 yield* start(fixture).pipe(Effect.provide(fixture.layer));
2370 assert.deepStrictEqual(
2371 (yield* Ref.get(fixture.commands)).map((command) => [command.type, command.threadId]),
2372 [
2373 ["thread.archive", ThreadId.make("plain-archive")],
2374 ["thread.auto-settle", ThreadId.make("inactive")],
2375 ],
2376 );
2377 }),
2378 ),
2379 );
2380
2381 it.effect("observes configured work PRs from the repository's main checkout", () =>
2382 Effect.scoped(
2383 Effect.gen(function* () {
2384 yield* TestClock.setTime(Date.parse(NOW));
2385 const thread = makeThread("root-work-pr", {
2386 settledOverride: "settled",
2387 settledAt: DateTime.makeUnsafe(NOW),
2388 worktreePath: null,
2389 pullRequests: [link(1)],
2390 });
2391 const fixture = yield* makeHarness({
2392 settings,
2393 snapshot: makeSnapshot([thread]),
2394 managedLookup: () => Effect.fail(new ManagedWorkspaceError({ reason: "workspace" })),
2395 mergebotDetail: () =>
2396 Effect.succeed(mergebotDetail(1, { lifecycle: "merged", mergedAt: NOW })),
2397 });
2398 yield* start(fixture).pipe(Effect.provide(fixture.layer));
2399 assert.deepStrictEqual(
2400 (yield* Ref.get(fixture.commands)).map((command) => command.type),
2401 ["thread.archive"],
2402 );
2403 }),
2404 ),
2405 );
2406
2407 it.effect(
2408 "settling after merge archives immediately and skips the managed workspace settle script",
2409 () =>
2410 Effect.scoped(
2411 Effect.gen(function* () {
2412 yield* TestClock.setTime(Date.parse(NOW));
2413 const thread = {
2414 ...makeThread("settle-after-merge", {
2415 settledOverride: "settled",
2416 settledAt: DateTime.makeUnsafe(NOW),
2417 worktreePath: "/workspace/managed",
2418 pullRequests: [link(1)],
2419 }),
2420 lastVisitedAt: null,
2421 };
2422 const archived = yield* Deferred.make<void>();
2423 const fixture = yield* makeHarness({
2424 settings,
2425 snapshot: makeSnapshot([]),
2426 currentThreads: [thread],
2427 managedThreadIds: [thread.id],
2428 existingWorktreePaths: ["/workspace/managed"],
2429 mergebotDetail: () =>
2430 Effect.succeed(mergebotDetail(1, { lifecycle: "merged", mergedAt: NOW })),
2431 onDispatch: (command) =>
2432 command.type === "thread.archive"
2433 ? Deferred.succeed(archived, undefined).pipe(Effect.asVoid)
2434 : Effect.void,
2435 });
2436 yield* Effect.gen(function* () {
2437 yield* start(fixture);
2438 yield* Ref.set(fixture.snapshots, makeSnapshot([thread]));
2439 yield* fixture.publishEvent({
2440 type: "thread.settled",
2441 id: EventId.make("settled-after-merge"),
2442 threadId: thread.id,
2443 occurredAt: DateTime.makeUnsafe(NOW),
2444 payload: thread,
2445 });
2446 yield* Deferred.await(archived);
2447 assert.equal(yield* Ref.get(fixture.fullArchiveReadCount), 1);
2448 assert.deepStrictEqual(yield* Ref.get(fixture.singleArchiveReads), [thread.id]);
2449 assert.equal(yield* Queue.size(fixture.scriptRuns), 0);
2450 assert.deepStrictEqual(
2451 (yield* Ref.get(fixture.commands)).map((command) => command.type),
2452 ["thread.archive"],
2453 );
2454 }).pipe(Effect.provide(fixture.layer));
2455 }),
2456 ),
2457 );
2458
2459 it.effect(
2460 "wakes failed current-head CI through a notification command without dispatching a message or starting a run",
2461 () =>
2462 Effect.scoped(
2463 Effect.gen(function* () {
2464 yield* TestClock.setTime(Date.parse(NOW));
2465 const thread = makeThread("ci-failed", {
2466 settledOverride: "settled",
2467 settledAt: DateTime.makeUnsafe(NOW),
2468 pullRequests: [link(1)],
2469 });
2470 const fixture = yield* makeHarness({
2471 settings,
2472 snapshot: makeSnapshot([thread]),
2473 managedThreadIds: [thread.id],
2474 mergebotDetail: () =>
2475 Effect.succeed(
2476 mergebotDetail(1, {
2477 checks: [failedCheck()],
2478 }),
2479 ),
2480 });
2481 yield* start(fixture).pipe(Effect.provide(fixture.layer));
2482 const commands = yield* Ref.get(fixture.commands);
2483 assert.equal(commands.length, 1);
2484 assert.equal(commands[0]?.type, "thread.ci-failed");
2485 if (commands[0]?.type !== "thread.ci-failed") return;
2486 assert.equal(commands[0].url, "https://mergebot.example.test/owner/repository/pull/1");
2487 assert.ok(commands[0].itemId.includes(":current-head:"));
2488 }),
2489 ),
2490 );
2491
2492 it.effect("records each failed check attempt once and wakes again for a same-head rerun", () =>
2493 Effect.scoped(
2494 Effect.gen(function* () {
2495 yield* TestClock.setTime(Date.parse(NOW));
2496 const thread = makeThread("rerun-ci", {
2497 settledOverride: "settled",
2498 settledAt: DateTime.makeUnsafe(NOW),
2499 pullRequests: [link(1)],
2500 });
2501 const original = failedCheck({ completedAt: "2026-08-28T11:00:00.000Z" });
2502 const first = yield* makeHarness({
2503 settings,
2504 snapshot: makeSnapshot([thread]),
2505 managedThreadIds: [thread.id],
2506 mergebotDetail: () => Effect.succeed(mergebotDetail(1, { checks: [original] })),
2507 });
2508 yield* start(first).pipe(Effect.provide(first.layer));
2509 const previous = (yield* Ref.get(first.commands))[0];
2510 assert.ok(previous?.type === "thread.ci-failed");
2511 for (const changed of [
2512 null,
2513 { completedAt: "2026-08-28T11:30:00.000Z" },
2514 { id: "rerun-check-2" },
2515 ]) {
2516 const fixture = yield* makeHarness({
2517 settings,
2518 snapshot: makeSnapshot([thread]),
2519 managedThreadIds: [thread.id],
2520 previousItemIds: new Set([previous.itemId]),
2521 mergebotDetail: () =>
2522 Effect.succeed(mergebotDetail(1, { checks: [{ ...original, ...changed }] })),
2523 });
2524 yield* start(fixture).pipe(Effect.provide(fixture.layer));
2525 const commands = yield* Ref.get(fixture.commands);
2526 assert.equal(commands.length, changed === null ? 0 : 1);
2527 if (commands[0]?.type === "thread.ci-failed")
2528 assert.notEqual(commands[0].itemId, previous.itemId);
2529 }
2530 }),
2531 ),
2532 );
2533
2534 it.effect("ignores stale snapshots and failures already recorded for the current head", () =>
2535 Effect.scoped(
2536 Effect.gen(function* () {
2537 yield* TestClock.setTime(Date.parse(NOW));
2538 const thread = makeThread("known-ci", {
2539 settledOverride: "settled",
2540 settledAt: DateTime.makeUnsafe(NOW),
2541 pullRequests: [link(1)],
2542 });
2543 for (const previousCiNotice of [false, true]) {
2544 const fixture = yield* makeHarness({
2545 settings,
2546 snapshot: makeSnapshot([thread]),
2547 previousCiNotice,
2548 mergebotDetail: () =>
2549 Effect.succeed(
2550 previousCiNotice ? mergebotDetail(1, { checks: [failedCheck()] }) : null,
2551 ),
2552 });
2553 yield* start(fixture).pipe(Effect.provide(fixture.layer));
2554 expect(yield* Ref.get(fixture.commands)).toEqual([]);
2555 expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0);
2556 }
2557 }),
2558 ),
2559 );
2560});
apps/server/src/orchestration-v2/ThreadSettlementService.ts+439-37
...@@ -1,10 +1,19 @@...@@ -1,10 +1,19 @@
1import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
2import { resolveProjectSettings } from "@t3tools/shared/projectSettings";1import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
3import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";2import {
3 threadPullRequestKeyOf,
4 normalizeThreadPullRequestKey,
5 legacyThreadPullRequestKey,
6 visibleThreadPullRequests,
7} from "@t3tools/shared/threadPullRequests";
4import {8import {
5 CommandId,9 CommandId,
10 TurnItemId,
11 MergebotPullRequestCheck,
12 isMergebotRepository,
6 type ThreadId,13 type ThreadId,
7 type OrchestrationV2DomainEvent,14 type PullRequestRef,
15 type OrchestrationV2AppThread,
16 type OrchestrationV2StoredEvent,
8 type OrchestrationV2ThreadShell,17 type OrchestrationV2ThreadShell,
9} from "@t3tools/contracts";18} from "@t3tools/contracts";
10import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";19import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
...@@ -15,6 +24,8 @@ import * as DateTime from "effect/DateTime";...@@ -15,6 +24,8 @@ import * as DateTime from "effect/DateTime";
15import * as Effect from "effect/Effect";24import * as Effect from "effect/Effect";
16import * as FileSystem from "effect/FileSystem";25import * as FileSystem from "effect/FileSystem";
17import * as Layer from "effect/Layer";26import * as Layer from "effect/Layer";
27import * as Schema from "effect/Schema";
28import * as Hex from "effect/encoding/Hex";
18import * as Schedule from "effect/Schedule";29import * as Schedule from "effect/Schedule";
19import type * as Scope from "effect/Scope";30import type * as Scope from "effect/Scope";
20import * as Stream from "effect/Stream";31import * as Stream from "effect/Stream";
...@@ -23,11 +34,30 @@ import * as GitManager from "../git/GitManager.ts";...@@ -23,11 +34,30 @@ import * as GitManager from "../git/GitManager.ts";
23import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";34import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
24import * as PullRequestService from "../pullRequest/PullRequestService.ts";35import * as PullRequestService from "../pullRequest/PullRequestService.ts";
25import * as ServerSettings from "../serverSettings.ts";36import * as ServerSettings from "../serverSettings.ts";
37import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts";
26import { forkParked } from "../serverActivation.ts";38import { forkParked } from "../serverActivation.ts";
27import * as TerminalManager from "../terminal/Manager.ts";39import * as TerminalManager from "../terminal/Manager.ts";
40import * as PreviewManager from "../preview/Manager.ts";
28import * as ProjectStore from "./ProjectStore.ts";41import * as ProjectStore from "./ProjectStore.ts";
29import * as Orchestrator from "./Orchestrator.ts";42import * as Orchestrator from "./Orchestrator.ts";
30import * as ProjectionStore from "./ProjectionStore.ts";43import * as ProjectionStore from "./ProjectionStore.ts";
44import * as ManagedWorkspaces from "../workspace/ManagedWorkspaces.ts";
45import * as DevServer from "../workspace/DevServer.ts";
46import * as MergebotQueue from "../mergebot/MergebotQueue.ts";
47import { isManagedWorkspaceArchiveCandidate } from "../workspace/managedWorkspaceArchive.ts";
48
49const encodeCiFailureIdentity = Schema.encodeSync(
50 Schema.fromJsonString(
51 Schema.Array(
52 MergebotPullRequestCheck.mapFields(({ id, status, conclusion, completedAt }) => ({
53 id,
54 status,
55 conclusion,
56 completedAt,
57 })),
58 ),
59 ),
60);
3161
32export interface SettlementPullRequest {62export interface SettlementPullRequest {
33 readonly state: "open" | "closed" | "merged";63 readonly state: "open" | "closed" | "merged";
...@@ -35,6 +65,20 @@ export interface SettlementPullRequest {...@@ -35,6 +65,20 @@ export interface SettlementPullRequest {
35 readonly mergedAt?: string | null;65 readonly mergedAt?: string | null;
36}66}
3767
68export function lastSaturdayArchiveBoundary(nowMs: number): number {
69 const now = DateTime.makeZonedUnsafe(nowMs, { timeZone: DateTime.zoneMakeLocal() });
70 const days = (DateTime.toParts(now).weekDay + 1) % 7;
71 const boundary = DateTime.setParts(DateTime.subtract(now, { days }), {
72 hour: 9,
73 minute: 0,
74 second: 0,
75 millisecond: 0,
76 });
77 return DateTime.toEpochMillis(
78 DateTime.toEpochMillis(boundary) > nowMs ? DateTime.subtract(boundary, { weeks: 1 }) : boundary,
79 );
80}
81
38const DAY_MS = 24 * 60 * 60 * 1_000;82const DAY_MS = 24 * 60 * 60 * 1_000;
39export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;83export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;
4084
...@@ -144,10 +188,8 @@ export function isAutoSettlementCandidate(...@@ -144,10 +188,8 @@ export function isAutoSettlementCandidate(
144 if (thread.pinnedAt != null || thread.autoSettleDisabledAt != null) return false;188 if (thread.pinnedAt != null || thread.autoSettleDisabledAt != null) return false;
145 // Blocked-on-you work must never park behind a settled override.189 // Blocked-on-you work must never park behind a settled override.
146 if (thread.pendingRuntimeRequest !== null) return false;190 if (thread.pendingRuntimeRequest !== null) return false;
147 // A live run, or background work that will wake the agent, is not
148 // staleness. A dev server left running is: the agent is done.
149 if (thread.activityRunStatus != null) return false;191 if (thread.activityRunStatus != null) return false;
150 if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) return false;192 if ((thread.pendingBackgroundTasks?.length ?? 0) > 0) return false;
151 if (threadHasQueuedTurnStart(thread, nowMs)) return false;193 if (threadHasQueuedTurnStart(thread, nowMs)) return false;
152 const snoozedUntilMs = toMillis(thread.snoozedUntil);194 const snoozedUntilMs = toMillis(thread.snoozedUntil);
153 if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return true;195 if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return true;
...@@ -203,12 +245,17 @@ export function resolveAutoSettlementAt(input: {...@@ -203,12 +245,17 @@ export function resolveAutoSettlementAt(input: {
203 readonly autoSettleAfterDays: number | null;245 readonly autoSettleAfterDays: number | null;
204 readonly autoSettleOnMerge: boolean;246 readonly autoSettleOnMerge: boolean;
205 readonly autoSettleKeepPullRequests?: boolean;247 readonly autoSettleKeepPullRequests?: boolean;
248 readonly usePullRequestSnapshots?: boolean;
206}): DateTime.Utc | null {249}): DateTime.Utc | null {
207 const { thread } = input;250 const { thread } = input;
208 let pullRequest = input.pullRequest;251 let pullRequest = input.pullRequest;
209 const links = visibleThreadPullRequests(thread.pullRequests ?? []);252 const links = visibleThreadPullRequests(thread.pullRequests ?? []);
210 if (links.some((link) => link.snapshot === null || link.snapshot.state === "open")) return null;253 if (
211 if (links.length > 0) {254 input.usePullRequestSnapshots !== false &&
255 links.some((link) => link.snapshot === null || link.snapshot.state === "open")
256 )
257 return null;
258 if (input.usePullRequestSnapshots !== false && links.length > 0) {
212 const terminalAt = (link: (typeof links)[number]) => {259 const terminalAt = (link: (typeof links)[number]) => {
213 const snapshot = link.snapshot;260 const snapshot = link.snapshot;
214 const value = snapshot?.state === "merged" ? snapshot.mergedAt : snapshot?.closedAt;261 const value = snapshot?.state === "merged" ? snapshot.mergedAt : snapshot?.closedAt;
...@@ -238,7 +285,10 @@ export function resolveAutoSettlementAt(input: {...@@ -238,7 +285,10 @@ export function resolveAutoSettlementAt(input: {
238 return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs);285 return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs);
239 }286 }
240 const hasPullRequest =287 const hasPullRequest =
241 pullRequest !== null || thread.linkedPullRequest != null || thread.branchPullRequest != null;288 links.length > 0 ||
289 pullRequest !== null ||
290 thread.linkedPullRequest != null ||
291 thread.branchPullRequest != null;
242 if (input.autoSettleKeepPullRequests === true && hasPullRequest) return null;292 if (input.autoSettleKeepPullRequests === true && hasPullRequest) return null;
243 if (input.autoSettleAfterDays === null || activityAtMs === null) return null;293 if (input.autoSettleAfterDays === null || activityAtMs === null) return null;
244 // A passed wake timer restarts inactivity. An earlier wake can only delay settlement.294 // A passed wake timer restarts inactivity. An earlier wake can only delay settlement.
...@@ -312,15 +362,215 @@ export const make = Effect.gen(function* () {...@@ -312,15 +362,215 @@ export const make = Effect.gen(function* () {
312 const crypto = yield* Crypto.Crypto;362 const crypto = yield* Crypto.Crypto;
313 const fileSystem = yield* FileSystem.FileSystem;363 const fileSystem = yield* FileSystem.FileSystem;
314 const terminals = yield* TerminalManager.TerminalManager;364 const terminals = yield* TerminalManager.TerminalManager;
365 const previews = yield* PreviewManager.PreviewManager;
315 const projectScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;366 const projectScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
367 const managedWorkspaces = yield* ManagedWorkspaces.ManagedWorkspaces;
368 const devServer = yield* DevServer.DevServer;
369 const mergebot = yield* MergebotQueue.MergebotQueue;
370 const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore;
316 // Settling a settled thread re-emits thread.settled with the same settledAt,371 // Settling a settled thread re-emits thread.settled with the same settledAt,
317 // so this keeps the settle action to one run per settlement.372 // so this keeps the settle action to one run per settlement.
318 const settleActionRunAt = new Map<ThreadId, number>();373 const settleActionRunAt = new Map<ThreadId, number>();
319374
375 const configuredRepository = (reference: {
376 readonly host?: string | undefined;
377 readonly repository: string;
378 }) =>
379 reference.host?.toLowerCase() === "github.com"
380 ? mergebot.configuration?.repositories.find(
381 (repository) => repository.toLowerCase() === reference.repository.toLowerCase(),
382 )
383 : undefined;
384
385 const hasOpenResources = Effect.fn("ThreadSettlementServiceV2.hasOpenResources")(function* (
386 threadId: ThreadId,
387 ) {
388 if ((yield* terminals.list({ threadId })).length > 0) return true;
389 return (yield* previews.list({ threadId })).sessions.length > 0;
390 });
391
392 const stopUnusedDevServer = Effect.fn("ThreadSettlementServiceV2.stopUnusedDevServer")(
393 (threadId: ThreadId) => devServer.stopUnused(threadId),
394 (effect, threadId) =>
395 effect.pipe(
396 Effect.catchCause((cause) =>
397 Cause.hasInterruptsOnly(cause)
398 ? Effect.failCause(cause)
399 : Effect.logWarning("stopping an unused development server failed", {
400 threadId,
401 cause: Cause.pretty(cause),
402 }),
403 ),
404 ),
405 );
406
407 const archiveSettledThreads = Effect.fn("ThreadSettlementServiceV2.archiveSettledThreads")(
408 function* (threadId?: ThreadId) {
409 const snapshot =
410 threadId === undefined
411 ? yield* projections.getShellSnapshot()
412 : yield* Effect.gen(function* () {
413 const snapshotSequence = yield* eventStore.latestAgentSequence(threadId);
414 const thread = yield* projections.getThreadShell(threadId);
415 return {
416 snapshotSequence,
417 threads: thread === null ? [] : [thread],
418 archivedThreads: [],
419 };
420 });
421 const parked = [...snapshot.threads, ...snapshot.archivedThreads].filter(
422 (thread) => thread.settledOverride === "settled" || thread.archivedAt !== null,
423 );
424 yield* Effect.forEach(parked, (thread) => stopUnusedDevServer(thread.id), {
425 concurrency: 8,
426 discard: true,
427 });
428 const nowMs = DateTime.toEpochMillis(yield* DateTime.now);
429 const boundary = lastSaturdayArchiveBoundary(nowMs);
430 const candidates = snapshot.threads
431 .filter(isManagedWorkspaceArchiveCandidate)
432 .filter(
433 (thread) =>
434 (threadId === undefined || thread.id === threadId) &&
435 !threadHasQueuedTurnStart(thread, nowMs),
436 );
437 const readPullRequest = Effect.fn("ThreadSettlementServiceV2.observePullRequest")(function* (
438 reference: PullRequestRef,
439 ) {
440 const repository = configuredRepository(reference);
441 if (repository === undefined) {
442 yield* pullRequests.invalidate({ reference }, { notifyReaders: false });
443 const detail = yield* pullRequests.detail({ ...reference, allowStale: false });
444 return { ...detail, failureIdentity: null };
445 }
446 const detail = yield* mergebot.readPullRequest({ repository, number: reference.number });
447 if (detail === null) return null;
448 const failedChecks = detail.checks.filter((check) =>
449 ["failure", "action_required", "timed_out", "startup_failure"].includes(
450 check.conclusion ?? "",
451 ),
452 );
453 const failed =
454 detail.lifecycle === "open" &&
455 (failedChecks.length > 0 ||
456 detail.ciStatus === "failed" ||
457 detail.queueStatus === "ci_failed");
458 const identity = encodeCiFailureIdentity(
459 failedChecks.toSorted((left, right) => left.id.localeCompare(right.id)),
460 );
461 return {
462 ...detail,
463 repository,
464 state: detail.lifecycle,
465 failureIdentity: failed
466 ? Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode(identity)))
467 : null,
468 };
469 });
470 const reads = new Map<string, ReturnType<typeof readPullRequest>>();
471 const targets = (yield* Effect.forEach(
472 candidates,
473 (thread) =>
474 Effect.gen(function* () {
475 const links = visibleThreadPullRequests(thread.pullRequests ?? []);
476 const workPullRequest = links.some(
477 (link) =>
478 normalizeThreadPullRequestKey(link).host === "github.com" &&
479 isMergebotRepository(mergebot.configuration ?? undefined, link.repository),
480 );
481 const work =
482 links.length > 0 &&
483 (workPullRequest || (yield* managedWorkspaces.isManagedThread(thread.id)));
484 return { thread, links, work };
485 }).pipe(
486 Effect.catchTags({
487 ManagedWorkspaceError: () =>
488 Effect.logWarning("automatic thread archival workspace lookup skipped", {
489 threadId: thread.id,
490 }).pipe(Effect.as(null)),
491 }),
492 ),
493 { concurrency: 8 },
494 )).filter((target) => target !== null);
495 for (const { thread, links, work } of targets) {
496 if (!work) continue;
497 for (const link of links) {
498 const reference = {
499 projectId: thread.projectId,
500 host: link.host,
501 repository: link.repository,
502 number: link.number,
503 };
504 const key = `${reference.projectId}:${threadPullRequestKeyOf(reference)}`;
505 if (!reads.has(key)) reads.set(key, yield* Effect.cached(readPullRequest(reference)));
506 }
507 }
508 yield* Effect.forEach(
509 targets,
510 ({ thread, links, work }) =>
511 Effect.gen(function* () {
512 let merged = false;
513 if (work) {
514 const observed = yield* Effect.forEach(
515 links,
516 (link) =>
517 reads.get(`${thread.projectId}:${threadPullRequestKeyOf(link)}`) ??
518 Effect.die("Pull request observation was not prepared"),
519 );
520 for (const failed of observed) {
521 if (failed === null || failed.failureIdentity === null) continue;
522 const itemId = TurnItemId.make(
523 `server:ci-failed:${thread.id}:${failed.repository}:${failed.number}:${failed.headSha}:${failed.failureIdentity}`,
524 );
525 const previous = yield* projections.getTurnItem({ threadId: thread.id, itemId });
526 if (previous === null) {
527 const uuid = yield* crypto.randomUUIDv4();
528 yield* orchestrator.dispatch({
529 type: "thread.ci-failed",
530 commandId: CommandId.make(`server:ci-failed:${thread.id}:${uuid}`),
531 threadId: thread.id,
532 snapshotSequence: snapshot.snapshotSequence,
533 itemId,
534 pullRequestNumber: failed.number,
535 url: failed.url,
536 });
537 return;
538 }
539 }
540 merged =
541 observed.length > 0 && observed.every((detail) => detail?.state === "merged");
542 }
543 if (!merged) {
544 const settledAt = yield* eventStore.getThreadSettlementTime(thread.id);
545 if (settledAt === null || DateTime.toEpochMillis(settledAt) > boundary) return;
546 }
547 const uuid = yield* crypto.randomUUIDv4();
548 yield* orchestrator.dispatch({
549 type: "thread.archive",
550 commandId: CommandId.make(`server:auto-archive:${thread.id}:${uuid}`),
551 threadId: thread.id,
552 snapshotSequence: snapshot.snapshotSequence,
553 });
554 }).pipe(
555 Effect.catchCause((cause) =>
556 Cause.hasInterruptsOnly(cause)
557 ? Effect.failCause(cause)
558 : Effect.logWarning("automatic thread archival skipped", {
559 threadId: thread.id,
560 cause: Cause.pretty(cause),
561 }),
562 ),
563 ),
564 { concurrency: 8, discard: true },
565 );
566 },
567 );
568
320 const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* (569 const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* (
321 mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,570 mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,
322 threadId?: ThreadId,571 threadId?: ThreadId,
323 ) {572 ) {
573 yield* archiveSettledThreads(threadId);
324 const settings = yield* settingsService.getSettings;574 const settings = yield* settingsService.getSettings;
325 if (!autoSettlementConfigured(settings)) {575 if (!autoSettlementConfigured(settings)) {
326 return;576 return;
...@@ -331,6 +581,23 @@ export const make = Effect.gen(function* () {...@@ -331,6 +581,23 @@ export const make = Effect.gen(function* () {
331 const projectShells = yield* projectStore.listShells();581 const projectShells = yield* projectStore.listShells();
332 const nowMs = DateTime.toEpochMillis(yield* DateTime.now);582 const nowMs = DateTime.toEpochMillis(yield* DateTime.now);
333 const projects = new Map(projectShells.map((project) => [project.id, project]));583 const projects = new Map(projectShells.map((project) => [project.id, project]));
584 const workRepositoryFor = (thread: (typeof threads)[number]) => {
585 for (const link of visibleThreadPullRequests(thread.pullRequests ?? [])) {
586 const repository = configuredRepository(normalizeThreadPullRequestKey(link));
587 if (repository !== undefined) return repository;
588 }
589 const reference = thread.linkedPullRequest ?? thread.branchPullRequest;
590 if (reference != null) {
591 const repository = configuredRepository(legacyThreadPullRequestKey(reference));
592 if (repository !== undefined) return repository;
593 }
594 const canonical = projects
595 .get(thread.projectId)
596 ?.repositoryIdentity?.canonicalKey.toLowerCase();
597 return mergebot.configuration?.repositories.find(
598 (repository) => canonical === `github.com/${repository.toLowerCase()}`,
599 );
600 };
334 // A merge event re-sweeps every candidate, not just the threads linked to601 // A merge event re-sweeps every candidate, not just the threads linked to
335 // the merged pull request: most threads carry no link and settle from602 // the merged pull request: most threads carry no link and settle from
336 // their branch lookup, which would otherwise wait for the next minute's603 // their branch lookup, which would otherwise wait for the next minute's
...@@ -339,6 +606,7 @@ export const make = Effect.gen(function* () {...@@ -339,6 +606,7 @@ export const make = Effect.gen(function* () {
339606
340 const settleThread = Effect.fn("ThreadSettlementServiceV2.settleThread")(607 const settleThread = Effect.fn("ThreadSettlementServiceV2.settleThread")(
341 function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) {608 function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) {
609 if (yield* hasOpenResources(thread.id)) return null;
342 const currentSettings = resolveProjectSettings(610 const currentSettings = resolveProjectSettings(
343 yield* settingsService.getSettings,611 yield* settingsService.getSettings,
344 thread.projectId,612 thread.projectId,
...@@ -347,6 +615,7 @@ export const make = Effect.gen(function* () {...@@ -347,6 +615,7 @@ export const make = Effect.gen(function* () {
347 const settledAt = resolveAutoSettlementAt({615 const settledAt = resolveAutoSettlementAt({
348 thread,616 thread,
349 pullRequest,617 pullRequest,
618 usePullRequestSnapshots: workRepositoryFor(thread) === undefined,
350 nowMs: DateTime.toEpochMillis(decisionNow),619 nowMs: DateTime.toEpochMillis(decisionNow),
351 autoSettleAfterDays: currentSettings.sidebarAutoSettleAfterDays,620 autoSettleAfterDays: currentSettings.sidebarAutoSettleAfterDays,
352 autoSettleOnMerge: currentSettings.sidebarAutoSettleOnMerge,621 autoSettleOnMerge: currentSettings.sidebarAutoSettleOnMerge,
...@@ -384,7 +653,11 @@ export const make = Effect.gen(function* () {...@@ -384,7 +653,11 @@ export const make = Effect.gen(function* () {
384 { concurrency: 8 },653 { concurrency: 8 },
385 ))654 ))
386 .filter((thread) => thread !== null)655 .filter((thread) => thread !== null)
387 .filter((thread) => visibleThreadPullRequests(thread.pullRequests ?? []).length === 0);656 .filter(
657 (thread) =>
658 visibleThreadPullRequests(thread.pullRequests ?? []).length === 0 ||
659 workRepositoryFor(thread) !== undefined,
660 );
388 // Use the same cwd as the sidebar so both paths share GitManager's PR cache.661 // Use the same cwd as the sidebar so both paths share GitManager's PR cache.
389 const lookupCwdByThreadId = new Map<string, string>();662 const lookupCwdByThreadId = new Map<string, string>();
390 yield* Effect.forEach(663 yield* Effect.forEach(
...@@ -412,7 +685,16 @@ export const make = Effect.gen(function* () {...@@ -412,7 +685,16 @@ export const make = Effect.gen(function* () {
412 // its branch threads now instead of on a later sweep. Threads linked to685 // its branch threads now instead of on a later sweep. Threads linked to
413 // the merged pull request settle from the event itself below and need no686 // the merged pull request settle from the event itself below and need no
414 // lookup, so they are absent from this map by construction.687 // lookup, so they are absent from this map by construction.
415 const cwds = [...new Set(lookupCwdByThreadId.values())];688 const cwds = [
689 ...new Set(
690 lookupCandidates
691 .filter((thread) => workRepositoryFor(thread) === undefined)
692 .flatMap((thread) => {
693 const cwd = lookupCwdByThreadId.get(thread.id);
694 return cwd === undefined ? [] : [cwd];
695 }),
696 ),
697 ];
416 yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), {698 yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), {
417 concurrency: 8,699 concurrency: 8,
418 discard: true,700 discard: true,
...@@ -450,6 +732,7 @@ export const make = Effect.gen(function* () {...@@ -450,6 +732,7 @@ export const make = Effect.gen(function* () {
450 resolveAutoSettlementAt({732 resolveAutoSettlementAt({
451 thread,733 thread,
452 pullRequest,734 pullRequest,
735 usePullRequestSnapshots: workRepositoryFor(thread) === undefined,
453 nowMs: DateTime.toEpochMillis(decisionNow),736 nowMs: DateTime.toEpochMillis(decisionNow),
454 autoSettleAfterDays: settings.sidebarAutoSettleAfterDays,737 autoSettleAfterDays: settings.sidebarAutoSettleAfterDays,
455 autoSettleOnMerge: settings.sidebarAutoSettleOnMerge,738 autoSettleOnMerge: settings.sidebarAutoSettleOnMerge,
...@@ -464,6 +747,91 @@ export const make = Effect.gen(function* () {...@@ -464,6 +747,91 @@ export const make = Effect.gen(function* () {
464 ) {747 ) {
465 const thread = group[0]!;748 const thread = group[0]!;
466 const reference = thread.linkedPullRequest ?? thread.branchPullRequest;749 const reference = thread.linkedPullRequest ?? thread.branchPullRequest;
750 const workRepository = workRepositoryFor(thread);
751 if (workRepository !== undefined) {
752 const links = visibleThreadPullRequests(thread.pullRequests ?? []);
753 const references =
754 links.length > 0
755 ? links.map(normalizeThreadPullRequestKey)
756 : reference === null || reference === undefined
757 ? []
758 : [legacyThreadPullRequestKey(reference)];
759 const observed =
760 references.length > 0
761 ? yield* Effect.forEach(references, (link) =>
762 Effect.gen(function* () {
763 const repository = configuredRepository(link);
764 if (repository !== undefined) {
765 const detail = yield* mergebot.readPullRequest({
766 repository,
767 number: link.number,
768 });
769 return detail === null
770 ? null
771 : {
772 state: detail.lifecycle,
773 mergedAt: detail.mergedAt ?? null,
774 closedAt: detail.closedAt ?? null,
775 };
776 }
777 const detail = yield* pullRequests.summary(
778 { projectId: thread.projectId, ...link },
779 { recoverTransientFailure: false },
780 );
781 return {
782 state: detail.state,
783 mergedAt: detail.mergedAt ?? null,
784 closedAt: detail.closedAt ?? null,
785 };
786 }),
787 )
788 : thread.branch === null
789 ? []
790 : [
791 yield* mergebot
792 .findPullRequest({ repository: workRepository, headBranch: thread.branch })
793 .pipe(
794 Effect.map((detail) =>
795 detail === null
796 ? null
797 : {
798 state: detail.lifecycle,
799 mergedAt: detail.mergedAt ?? null,
800 closedAt: detail.closedAt ?? null,
801 },
802 ),
803 ),
804 ];
805 const terminal = observed.filter((detail) => detail !== null);
806 if (
807 terminal.length === 0 ||
808 terminal.length !== observed.length ||
809 terminal.some(
810 (detail) =>
811 detail.state === "open" ||
812 !Number.isFinite(
813 Date.parse((detail.state === "merged" ? detail.mergedAt : detail.closedAt) ?? ""),
814 ),
815 )
816 )
817 return null;
818 if (references.length > 0 && thread.branch !== null) {
819 const current = yield* mergebot.findPullRequest({
820 repository: workRepository,
821 headBranch: thread.branch,
822 });
823 if (current?.lifecycle === "open") return null;
824 }
825 return terminal.reduce((latest, detail) => {
826 const latestAt = Date.parse(
827 (latest.state === "merged" ? latest.mergedAt : latest.closedAt) ?? "",
828 );
829 const detailAt = Date.parse(
830 (detail.state === "merged" ? detail.mergedAt : detail.closedAt) ?? "",
831 );
832 return detailAt > latestAt ? detail : latest;
833 });
834 }
467 if (reference != null) {835 if (reference != null) {
468 // The event carries the merged state, so only the threads linked to836 // The event carries the merged state, so only the threads linked to
469 // that exact pull request settle from it. Every other linked thread837 // that exact pull request settle from it. Every other linked thread
...@@ -565,24 +933,41 @@ export const make = Effect.gen(function* () {...@@ -565,24 +933,41 @@ export const make = Effect.gen(function* () {
565 runSweep(null, threadId),933 runSweep(null, threadId),
566 );934 );
567935
568 // Settling closes the thread's shells that sit at an idle prompt, so they stop
569 // holding the worktree. A terminal running a command (a dev server, an
570 // editor) stays for the user to close. Then the project's settle script runs
571 // in the thread's own worktree; a thread in the shared checkout skips it,
572 // because other threads may still be working there.
573 const cleanUpSettledThread = Effect.fn("ThreadSettlementServiceV2.cleanUpSettledThread")(936 const cleanUpSettledThread = Effect.fn("ThreadSettlementServiceV2.cleanUpSettledThread")(
574 function* (threadId: ThreadId) {937 function* (settlement: OrchestrationV2AppThread, automaticSequence: number | undefined) {
938 const threadId = settlement.id;
939 const settledAtMs = toMillis(settlement.settledAt);
575 // A thread re-engaged before this event ran keeps its shells.940 // A thread re-engaged before this event ran keeps its shells.
576 const settled = yield* projections.getThread(threadId);941 const settled = yield* projections.getThread(threadId);
577 if (settled.settledOverride !== "settled") return;942 if (settled.settledOverride !== "settled" || toMillis(settled.settledAt) !== settledAtMs)
578 yield* terminals.closeIdle({ threadId });943 return;
944 if (automaticSequence !== undefined) {
945 if (yield* hasOpenResources(threadId)) {
946 const uuid = yield* crypto.randomUUIDv4();
947 yield* orchestrator.dispatch({
948 type: "thread.unsettle",
949 commandId: CommandId.make(`server:auto-settle-resources:${threadId}:${uuid}`),
950 threadId,
951 reason: "user",
952 snapshotSequence: automaticSequence,
953 });
954 return;
955 }
956 } else {
957 yield* terminals.close({ threadId });
958 const current = yield* projections.getThread(threadId);
959 if (current.settledOverride !== "settled" || toMillis(current.settledAt) !== settledAtMs)
960 return;
961 yield* previews.close({ threadId });
962 }
963 if (yield* managedWorkspaces.isManagedThread(threadId)) return;
579 const worktreePath = settled.worktreePath;964 const worktreePath = settled.worktreePath;
580 if (worktreePath === null || !(yield* fileSystem.exists(worktreePath))) return;965 if (worktreePath === null || !(yield* fileSystem.exists(worktreePath))) return;
581 // Closing and the worktree check wait on I/O. A thread re-engaged966 // Closing and the worktree check wait on I/O. A thread re-engaged
582 // meanwhile is working again, so its worktree is no place for cleanup.967 // meanwhile is working again, so its worktree is no place for cleanup.
583 const thread = yield* projections.getThread(threadId);968 const thread = yield* projections.getThread(threadId);
584 if (thread.settledOverride !== "settled") return;969 if (thread.settledOverride !== "settled" || toMillis(thread.settledAt) !== settledAtMs)
585 const settledAtMs = toMillis(thread.settledAt);970 return;
586 if (settledAtMs === null || settleActionRunAt.get(threadId) === settledAtMs) return;971 if (settledAtMs === null || settleActionRunAt.get(threadId) === settledAtMs) return;
587 const run = yield* projectScripts.runForThread({972 const run = yield* projectScripts.runForThread({
588 threadId,973 threadId,
...@@ -598,48 +983,60 @@ export const make = Effect.gen(function* () {...@@ -598,48 +983,60 @@ export const make = Effect.gen(function* () {
598 yield* run.completion.pipe(Effect.forkDetach);983 yield* run.completion.pipe(Effect.forkDetach);
599 }984 }
600 },985 },
601 (effect, threadId) =>986 (effect, settlement) =>
602 effect.pipe(987 effect.pipe(
603 Effect.catchCause((cause) =>988 Effect.catchCause((cause) =>
604 Cause.hasInterruptsOnly(cause)989 Cause.hasInterruptsOnly(cause)
605 ? Effect.failCause(cause)990 ? Effect.failCause(cause)
606 : Effect.logWarning("cleaning up a settled thread failed", {991 : Effect.logWarning("cleaning up a settled thread failed", {
607 threadId,992 threadId: settlement.id,
608 cause: Cause.pretty(cause),993 cause: Cause.pretty(cause),
609 }),994 }),
610 ),995 ),
611 ),996 ),
612 );997 );
613998
614 const processEvent = (event: OrchestrationV2DomainEvent) => {999 const processEvent = Effect.fn("ThreadSettlementServiceV2.processEvent")(function* (
1000 stored: OrchestrationV2StoredEvent,
1001 ) {
1002 const event = stored.event;
615 switch (event.type) {1003 switch (event.type) {
616 case "thread.settled":1004 case "thread.settled":
617 return cleanUpSettledThread(event.threadId);1005 return yield* cleanUpSettledThread(
1006 event.payload,
1007 stored.commandId?.startsWith("server:auto-settle:") ? stored.sequence : undefined,
1008 ).pipe(Effect.andThen(worker.enqueue(event.threadId)));
1009 case "thread.archived":
618 case "thread.pull-request-link-updated":1010 case "thread.pull-request-link-updated":
619 case "thread.pull-request-synced":1011 case "thread.pull-request-synced":
620 case "provider-session.detached":1012 case "provider-session.detached":
621 return worker.enqueue(event.threadId);1013 return yield* worker.enqueue(event.threadId);
622 case "provider-session.updated":1014 case "provider-session.updated":
623 return event.payload.status !== "starting" && event.payload.status !== "running"1015 if (event.payload.status !== "starting" && event.payload.status !== "running") {
624 ? worker.enqueue(event.threadId)1016 yield* worker.enqueue(event.threadId);
625 : Effect.void;1017 }
1018 return;
626 case "run.updated":1019 case "run.updated":
627 return ["completed", "interrupted", "failed", "cancelled", "rolled_back"].includes(1020 if (
628 event.payload.status,1021 ["completed", "interrupted", "failed", "cancelled", "rolled_back"].includes(
629 )1022 event.payload.status,
630 ? worker.enqueue(event.threadId)1023 )
631 : Effect.void;1024 ) {
1025 yield* worker.enqueue(event.threadId);
1026 }
1027 return;
632 default:1028 default:
633 return Effect.void;1029 return;
634 }1030 }
635 };1031 });
6361032
637 const start: ThreadSettlementServiceV2["Service"]["start"] = Effect.fn(1033 const start: ThreadSettlementServiceV2["Service"]["start"] = Effect.fn(
638 "ThreadSettlementServiceV2.start",1034 "ThreadSettlementServiceV2.start",
639 )(function* () {1035 )(function* () {
640 const settingsChanges = yield* settingsService.subscribeChanges;1036 const settingsChanges = yield* settingsService.subscribeChanges;
641 const mergedPullRequests = yield* pullRequests.subscribeMerges;1037 const mergedPullRequests = yield* pullRequests.subscribeMerges;
642 const events = orchestrator.streamDomainEvents;1038 const afterSequence = yield* eventStore.latestAgentSequence().pipe(Effect.orDie);
1039 const events = orchestrator.streamStoredEventsFrom({ afterSequence });
643 const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie);1040 const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie);
644 let lastSettlementSettings = autoSettlementSettingsKey(initialSettings);1041 let lastSettlementSettings = autoSettlementSettingsKey(initialSettings);
645 yield* forkParked(1042 yield* forkParked(
...@@ -659,6 +1056,11 @@ export const make = Effect.gen(function* () {...@@ -659,6 +1056,11 @@ export const make = Effect.gen(function* () {
659 }),1056 }),
660 );1057 );
661 yield* forkParked(Stream.runForEach(mergedPullRequests, (event) => runSweep(event)));1058 yield* forkParked(Stream.runForEach(mergedPullRequests, (event) => runSweep(event)));
1059 yield* forkParked(
1060 Stream.runForEach(mergebot.streamChanges.pipe(Stream.debounce("100 millis")), () =>
1061 worker.enqueue(undefined),
1062 ),
1063 );
662 yield* forkParked(1064 yield* forkParked(
663 Stream.runForEach(events, processEvent).pipe(1065 Stream.runForEach(events, processEvent).pipe(
664 Effect.catchCause((cause) =>1066 Effect.catchCause((cause) =>
apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts+48
...@@ -330,3 +330,51 @@ it.effect("replays a command's events through the command index, not the sequenc...@@ -330,3 +330,51 @@ it.effect("replays a command's events through the command index, not the sequenc
330 ),330 ),
331 ),331 ),
332);332);
333
334it.effect(
335 "dates a settlement from its first event, keeps duplicate settlements and imports stable, and resets on activation",
336 () =>
337 Effect.gen(function* () {
338 const store = yield* OrchestrationEventStore.OrchestrationEventStore;
339 const threadId = ThreadId.make("weekly-settlement");
340 const settled = { settledOverride: "settled", settledAt: "2026-08-01T00:00:00.000Z" };
341 const first = "2026-09-04T17:00:00.000Z";
342 const second = "2026-09-06T17:00:00.000Z";
343 const event = (ordinal: number, eventType: string, at: string, payload: unknown) => ({
344 ...eventRow(ordinal, "thread", 2, threadId),
345 event_type: eventType,
346 occurred_at: at,
347 payload_json: encodeJson(payload),
348 });
349 assert.equal(yield* store.getThreadSettlementTime(threadId), null);
350 yield* seedEvents([
351 event(1001, "thread.created", occurredAt, { settledOverride: null, settledAt: null }),
352 event(1002, "thread.settled", first, settled),
353 event(1003, "thread.settled", second, settled),
354 ]);
355 assert.deepStrictEqual(
356 yield* store.getThreadSettlementTime(threadId),
357 DateTime.makeUnsafe(first),
358 );
359 yield* seedEvents([
360 event(1004, "thread.unsettled", second, { settledOverride: "active", settledAt: null }),
361 event(1005, "thread.settled", second, settled),
362 ]);
363 assert.deepStrictEqual(
364 yield* store.getThreadSettlementTime(threadId),
365 DateTime.makeUnsafe(second),
366 );
367 const imported = ThreadId.make("legacy-settlement");
368 yield* seedEvents([
369 {
370 ...eventRow(1006, "thread", 2, imported),
371 event_type: "thread.created",
372 payload_json: encodeJson(settled),
373 },
374 ]);
375 assert.deepStrictEqual(
376 yield* store.getThreadSettlementTime(imported),
377 DateTime.makeUnsafe(settled.settledAt),
378 );
379 }).pipe(Effect.provide(layerEventStore)),
380);
apps/server/src/persistence/OrchestrationEventStore.ts+32
...@@ -30,6 +30,7 @@ import * as Context from "effect/Context";...@@ -30,6 +30,7 @@ import * as Context from "effect/Context";
30import * as SqlClient from "effect/sql/SqlClient";30import * as SqlClient from "effect/sql/SqlClient";
31import * as SqlSchema from "effect/sql/SqlSchema";31import * as SqlSchema from "effect/sql/SqlSchema";
32import * as Effect from "effect/Effect";32import * as Effect from "effect/Effect";
33import * as DateTime from "effect/DateTime";
33import * as Layer from "effect/Layer";34import * as Layer from "effect/Layer";
34import * as PubSub from "effect/PubSub";35import * as PubSub from "effect/PubSub";
35import * as Option from "effect/Option";36import * as Option from "effect/Option";
...@@ -123,6 +124,9 @@ export class OrchestrationEventStore extends Context.Service<...@@ -123,6 +124,9 @@ export class OrchestrationEventStore extends Context.Service<
123 ) => Effect.Effect<number, OrchestrationEventStoreError>;124 ) => Effect.Effect<number, OrchestrationEventStoreError>;
124125
125 readonly latestApplicationSequence: Effect.Effect<number, OrchestrationEventStoreError>;126 readonly latestApplicationSequence: Effect.Effect<number, OrchestrationEventStoreError>;
127 readonly getThreadSettlementTime: (
128 threadId: ThreadId,
129 ) => Effect.Effect<DateTime.Utc | null, OrchestrationEventStoreError>;
126130
127 /**131 /**
128 * Read the finite retained application-event range `(afterSequence, throughSequence]`.132 * Read the finite retained application-event range `(afterSequence, throughSequence]`.
...@@ -703,6 +707,33 @@ const makeEventStore = Effect.gen(function* () {...@@ -703,6 +707,33 @@ const makeEventStore = Effect.gen(function* () {
703 Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.latestAgentSequence:query")),707 Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.latestAgentSequence:query")),
704 );708 );
705709
710 const getThreadSettlementTime: OrchestrationEventStore["Service"]["getThreadSettlementTime"] = (
711 threadId,
712 ) =>
713 sql<{ readonly settled_at: string | null }>`
714 SELECT CASE WHEN event_type = 'thread.settled' THEN occurred_at
715 ELSE json_extract(payload_json, '$.settledAt') END AS settled_at
716 FROM orchestration_events INDEXED BY idx_orchestration_events_agent_stream_sequence
717 WHERE application_event_version = 2 AND aggregate_kind = 'thread' AND stream_id = ${threadId}
718 AND (event_type = 'thread.settled' OR
719 (event_type = 'thread.created' AND json_extract(payload_json, '$.settledOverride') = 'settled'))
720 AND sequence > COALESCE((
721 SELECT MAX(sequence) FROM orchestration_events INDEXED BY idx_orchestration_events_agent_stream_sequence
722 WHERE application_event_version = 2 AND aggregate_kind = 'thread' AND stream_id = ${threadId}
723 AND event_type LIKE 'thread.%'
724 AND json_type(payload_json, '$.settledOverride') IS NOT NULL
725 AND json_extract(payload_json, '$.settledOverride') IS NOT 'settled'
726 ), 0)
727 ORDER BY sequence ASC LIMIT 1
728 `.pipe(
729 Effect.map((rows) =>
730 rows[0]?.settled_at == null ? null : DateTime.makeUnsafe(rows[0].settled_at),
731 ),
732 Effect.mapError(
733 toPersistenceSqlError("OrchestrationEventStore.getThreadSettlementTime:query"),
734 ),
735 );
736
706 // The OR planner otherwise scans every V2 event instead of seeking the final sequence.737 // The OR planner otherwise scans every V2 event instead of seeking the final sequence.
707 const latestApplicationSequence = sql<{ readonly sequence: number | null }>`738 const latestApplicationSequence = sql<{ readonly sequence: number | null }>`
708 SELECT MAX(sequence) AS sequence739 SELECT MAX(sequence) AS sequence
...@@ -786,6 +817,7 @@ const makeEventStore = Effect.gen(function* () {...@@ -786,6 +817,7 @@ const makeEventStore = Effect.gen(function* () {
786 getReplayStats,817 getReplayStats,
787 latestAgentSequence,818 latestAgentSequence,
788 latestApplicationSequence,819 latestApplicationSequence,
820 getThreadSettlementTime,
789 readApplicationEvents: catchUpApplicationEvents,821 readApplicationEvents: catchUpApplicationEvents,
790 publishCommitted: (events) => PubSub.publishAll(committedEvents, events).pipe(Effect.asVoid),822 publishCommitted: (events) => PubSub.publishAll(committedEvents, events).pipe(Effect.asVoid),
791 streamApplicationEvents,823 streamApplicationEvents,
apps/server/src/server.ts+7
...@@ -119,6 +119,7 @@ import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolve...@@ -119,6 +119,7 @@ import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolve
119import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";119import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
120import * as MergebotQueue from "./mergebot/MergebotQueue.ts";120import * as MergebotQueue from "./mergebot/MergebotQueue.ts";
121import * as DevServer from "./workspace/DevServer.ts";121import * as DevServer from "./workspace/DevServer.ts";
122import * as ManagedWorkspaces from "./workspace/ManagedWorkspaces.ts";
122import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";123import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
123import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";124import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
124import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";125import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
...@@ -646,6 +647,12 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(...@@ -646,6 +647,12 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
646 Layer.effectDiscard(ProjectSettingsOverrideSweep.sweep),647 Layer.effectDiscard(ProjectSettingsOverrideSweep.sweep),
647).pipe(648).pipe(
648 // Core Services649 // Core Services
650 Layer.provideMerge(
651 ManagedWorkspaces.layer.pipe(
652 Layer.provide(ProcessRunner.layer),
653 Layer.provide(layerPullRequestService),
654 ),
655 ),
649 Layer.provideMerge(layerOrchestrationApplication),656 Layer.provideMerge(layerOrchestrationApplication),
650 Layer.provideMerge(RuntimeLayer.layerEventInfrastructure),657 Layer.provideMerge(RuntimeLayer.layerEventInfrastructure),
651 Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)),658 Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)),
apps/server/src/terminal/Manager.test.ts+34
...@@ -1827,6 +1827,40 @@ it.layer(...@@ -1827,6 +1827,40 @@ it.layer(
1827 }),1827 }),
1828 );1828 );
18291829
1830 it.effect(
1831 "lists and closes one chat's sessions without touching a peer in the same directory",
1832 () =>
1833 Effect.gen(function* () {
1834 const { manager, ptyAdapter } = yield* createManager();
1835 yield* manager.open(openInput({ terminalId: "default" }));
1836 yield* manager.open(openInput({ terminalId: "sidecar" }));
1837 yield* manager.open(openInput({ threadId: "peer-chat", terminalId: "default" }));
1838 const outputProcessed = yield* Deferred.make<void>();
1839 const unsubscribe = yield* manager.subscribe((event) =>
1840 event.type === "output" && event.threadId === "thread-1"
1841 ? Deferred.succeed(outputProcessed, undefined).pipe(Effect.asVoid)
1842 : Effect.void,
1843 );
1844 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1845 ptyAdapter.processes[0]!.emitData("valuable history\n");
1846 yield* Deferred.await(outputProcessed);
1847
1848 const listed = yield* manager.list({ threadId: "thread-1" });
1849 expect(listed.map((session) => session.terminalId).sort()).toEqual(["default", "sidecar"]);
1850 expect(listed.every((session) => session.cwd === listed[0]?.cwd)).toBe(true);
1851 yield* manager.close({ threadId: "thread-1" });
1852
1853 expect(yield* manager.list({ threadId: "thread-1" })).toEqual([]);
1854 expect(
1855 (yield* manager.list({ threadId: "peer-chat" })).map((session) => session.terminalId),
1856 ).toEqual(["default"]);
1857 expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([true, true, false]);
1858 expect((yield* manager.open(openInput({ terminalId: "default" }))).history).toBe(
1859 "valuable history\n",
1860 );
1861 }),
1862 );
1863
1830 it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () =>1864 it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () =>
1831 Effect.gen(function* () {1865 Effect.gen(function* () {
1832 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, {1866 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, {
apps/server/src/terminal/Manager.ts+7-2
...@@ -211,11 +211,14 @@ export class TerminalManager extends Context.Service<...@@ -211,11 +211,14 @@ export class TerminalManager extends Context.Service<
211 */211 */
212 readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>;212 readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>;
213213
214 readonly list: (input: {
215 readonly threadId: string;
216 }) => Effect.Effect<ReadonlyArray<TerminalSummary>>;
217
214 /**218 /**
215 * Close a thread's terminals that wait at an idle shell prompt. A terminal219 * Close a thread's terminals that wait at an idle shell prompt. A terminal
216 * that runs a command stays open. When `terminalId` is set, only that220 * that runs a command stays open. When `terminalId` is set, only that
217 * terminal is considered. Used when a thread settles and when a setup221 * terminal is considered.
218 * script finishes.
219 */222 */
220 readonly closeIdle: (input: {223 readonly closeIdle: (input: {
221 readonly threadId: string;224 readonly threadId: string;
...@@ -3214,6 +3217,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -3214,6 +3217,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
3214 clear,3217 clear,
3215 restart,3218 restart,
3216 close,3219 close,
3220 list: ({ threadId }) =>
3221 sessionsForThread(threadId).pipe(Effect.map((sessions) => sessions.map(summary))),
3217 closeIdle,3222 closeIdle,
3218 subscribe,3223 subscribe,
3219 subscribeMetadata,3224 subscribeMetadata,
apps/server/src/workspace/ManagedWorkspaces.archive.test.ts created+340
...@@ -0,0 +1,340 @@
1// @effect-diagnostics nodeBuiltinImport:off - Owns isolated filesystem fixtures.
2import { afterEach, beforeEach, describe, expect, vi } from "vite-plus/test";
3import { it } from "@effect/vitest";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import {
6 ProjectId,
7 ThreadId,
8 ProviderInstanceId,
9 SourceControlProviderKind,
10 type OrchestrationV2ThreadShell,
11 type ThreadPullRequestLink,
12 type PullRequestSummary,
13 PullRequestOperationError,
14} from "@t3tools/contracts";
15import * as HostProcess from "@t3tools/shared/HostProcess";
16import * as DateTime from "effect/DateTime";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
21import * as NodeFSP from "node:fs/promises";
22import * as NodeOS from "node:os";
23import * as NodePath from "node:path";
24
25import * as ServerConfig from "../config.ts";
26import * as ProcessRunner from "../processRunner.ts";
27import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
28import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
29import * as PullRequestService from "../pullRequest/PullRequestService.ts";
30import * as ManagedWorkspaces from "./ManagedWorkspaces.ts";
31
32const now = DateTime.makeUnsafe("2026-10-13T12:00:00.000Z");
33const projectId = ProjectId.make("managed-project");
34const threadId = ThreadId.make("managed-thread");
35let home: string;
36let workspacePath: string;
37let threads: OrchestrationV2ThreadShell[];
38const launches = vi.fn<ProcessRunner.ProcessRunner["Service"]["run"]>();
39const dispatch = vi.fn<Orchestrator.OrchestratorV2["Service"]["dispatch"]>();
40const summary = vi.fn<PullRequestService.PullRequestService["Service"]["summary"]>();
41const invalidate = vi.fn<PullRequestService.PullRequestService["Service"]["invalidate"]>();
42const link = (number: number): ThreadPullRequestLink => ({
43 host: "github.com",
44 repository: "work/app",
45 number,
46 url: `https://github.com/work/app/pull/${number}`,
47 source: "manual",
48 linkedAt: DateTime.formatIso(now),
49 snapshot: null,
50 stack: null,
51});
52const pullRequest = (state: PullRequestSummary["state"], number = 1): PullRequestSummary => ({
53 provider: SourceControlProviderKind.make("github"),
54 projectId,
55 repository: "work/app",
56 number,
57 title: "Work",
58 url: link(number).url,
59 state,
60 headBranch: "feature",
61 baseBranch: "main",
62 mergedAt: state === "merged" ? DateTime.formatIso(now) : null,
63 updatedAt: DateTime.formatIso(now),
64});
65const thread = (): OrchestrationV2ThreadShell => ({
66 id: threadId,
67 projectId,
68 title: "Managed workspace",
69 providerInstanceId: ProviderInstanceId.make("codex"),
70 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" },
71 runtimeMode: "full-access",
72 interactionMode: "default",
73 branch: null,
74 worktreePath: null,
75 activeProviderThreadId: null,
76 lineage: { rootThreadId: threadId, parentThreadId: null, relationshipToParent: null },
77 forkedFrom: null,
78 createdBy: "user",
79 creationSource: "web",
80 activeRunId: null,
81 latestRunId: null,
82 status: "idle",
83 pendingRuntimeRequest: null,
84 latestVisibleMessage: null,
85 latestUserMessageAt: null,
86 hasActionableProposedPlan: false,
87 itemCount: 0,
88 visibleItemCount: 0,
89 createdAt: now,
90 updatedAt: now,
91 archivedAt: null,
92 settledOverride: "settled",
93 settledAt: now,
94 deletedAt: null,
95 pullRequests: [link(1)],
96});
97
98beforeEach(async () => {
99 home = await NodeFSP.realpath(
100 await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-managed-archive-")),
101 );
102 workspacePath = NodePath.join(home, "workspaces/1");
103 await NodeFSP.mkdir(workspacePath, { recursive: true });
104 const records = NodePath.join(home, ".local/state/workspaces/workspaces");
105 await NodeFSP.mkdir(records, { recursive: true });
106 await NodeFSP.writeFile(NodePath.join(records, "1.json"), JSON.stringify({ threadId }));
107 await NodeFSP.mkdir(NodePath.join(home, ".config"));
108 await NodeFSP.mkdir(NodePath.join(home, "source/client"), { recursive: true });
109 await NodeFSP.mkdir(NodePath.join(home, "bin"));
110 await NodeFSP.writeFile(
111 NodePath.join(home, ".config/workspaces.json"),
112 JSON.stringify({ repository: NodePath.join(home, "source") }),
113 );
114 await NodeFSP.writeFile(
115 NodePath.join(home, "source/client/package.json"),
116 '{"name":"@work/client"}',
117 );
118 await NodeFSP.writeFile(NodePath.join(home, "bin/ws"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
119 threads = [thread()];
120 launches.mockReset();
121 launches.mockImplementation(() =>
122 Effect.succeed({
123 stdout: '{"abandoned":false}',
124 stderr: "",
125 code: ChildProcessSpawner.ExitCode(0),
126 timedOut: false,
127 stdoutTruncated: false,
128 stderrTruncated: false,
129 stdoutInvalidUtf8: false,
130 stderrInvalidUtf8: false,
131 }),
132 );
133 dispatch.mockReset();
134 summary.mockReset();
135 invalidate.mockReset();
136 dispatch.mockImplementation(() => Effect.succeed({ sequence: 1, storedEvents: [] }));
137 summary.mockImplementation((ref) => Effect.succeed(pullRequest("merged", ref.number)));
138 invalidate.mockImplementation(() => Effect.void);
139});
140afterEach(async () => {
141 await NodeFSP.rm(home, { recursive: true, force: true });
142});
143
144const layer = () =>
145 ManagedWorkspaces.layer.pipe(
146 Layer.provide(
147 Layer.mergeAll(
148 NodeServices.layer,
149 Layer.succeed(HostProcess.HomeDirectory, home),
150 Layer.succeed(HostProcess.Environment, { PATH: NodePath.join(home, "bin") }),
151 ServerConfig.layerTest(home, NodePath.join(home, "isolated-t3")).pipe(
152 Layer.provide(NodeServices.layer),
153 ),
154 Layer.succeed(ProcessRunner.ProcessRunner, { run: launches }),
155 Layer.mock(Orchestrator.OrchestratorV2)({
156 getThreadShell: (id) =>
157 Effect.succeed(threads.find((thread) => thread.id === id) ?? null),
158 getShellSnapshot: () =>
159 Effect.succeed({ schemaVersion: 1, snapshotSequence: 1, threads, archivedThreads: [] }),
160 dispatch,
161 }),
162 Layer.mock(ProjectStore.ProjectStoreV2)({
163 getShell: () =>
164 Effect.succeed(
165 Option.some({
166 id: projectId,
167 title: "Workspace",
168 workspaceRoot: workspacePath,
169 defaultModelSelection: null,
170 scripts: [],
171 createdAt: DateTime.formatIso(now),
172 updatedAt: DateTime.formatIso(now),
173 }),
174 ),
175 listShells: () =>
176 Effect.succeed([
177 {
178 id: projectId,
179 title: "Workspace",
180 workspaceRoot: workspacePath,
181 repositoryIdentity: null,
182 defaultModelSelection: null,
183 defaultThreadEnvMode: "local",
184 autoPull: false,
185 faviconPath: null,
186 projectIcon: null,
187 scripts: [],
188 createdAt: DateTime.formatIso(now),
189 updatedAt: DateTime.formatIso(now),
190 },
191 ]),
192 }),
193 Layer.mock(PullRequestService.PullRequestService)({ summary, invalidate }),
194 ),
195 ),
196 );
197const use = <A, E>(effect: Effect.Effect<A, E, ManagedWorkspaces.ManagedWorkspaces>) =>
198 effect.pipe(Effect.provide(layer()));
199
200describe("managed workspace archival", () => {
201 it.effect("archives JJ project-root threads only after every linked PR is freshly verified", () =>
202 use(
203 Effect.gen(function* () {
204 threads[0] = { ...threads[0]!, pullRequests: [link(1), link(2)] };
205 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
206 expect(yield* service.archiveCompleted({ workspacePath })).toEqual({
207 archivedThreadIds: [threadId],
208 });
209 expect(invalidate).toHaveBeenCalledTimes(2);
210 expect(summary).toHaveBeenCalledTimes(2);
211 expect(summary.mock.calls[0]).toEqual([
212 {
213 host: "github.com",
214 repository: "work/app",
215 number: 1,
216 projectId,
217 allowStale: false,
218 },
219 { recoverTransientFailure: false },
220 ]);
221 expect(dispatch.mock.calls[0]?.[0]).toMatchObject({
222 type: "thread.archive",
223 threadId,
224 snapshotSequence: 1,
225 });
226 }),
227 ),
228 );
229 it.effect.each(["open", "closed"] as const)("retains a workspace with any %s PR", (state) =>
230 use(
231 Effect.gen(function* () {
232 threads[0] = { ...threads[0]!, pullRequests: [link(1), link(2)] };
233 summary.mockImplementation((ref) =>
234 Effect.succeed(pullRequest(ref.number === 1 ? "merged" : state, ref.number)),
235 );
236 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
237 expect(yield* service.archiveCompleted({ workspacePath })).toEqual({
238 archivedThreadIds: [],
239 });
240 expect(dispatch).not.toHaveBeenCalled();
241 }),
242 ),
243 );
244 it.effect.each(["active", "unlinked", "shared active"] as const)(
245 "retains %s work without host reads",
246 (kind) =>
247 use(
248 Effect.gen(function* () {
249 if (kind === "active") threads[0] = { ...threads[0]!, settledOverride: "active" };
250 if (kind === "unlinked") threads[0] = { ...threads[0]!, pullRequests: [] };
251 if (kind === "shared active")
252 threads.push({ ...thread(), id: ThreadId.make("active-peer"), settledOverride: null });
253 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
254 expect(yield* service.archiveCompleted({ workspacePath })).toEqual({
255 archivedThreadIds: [],
256 });
257 expect(dispatch).not.toHaveBeenCalled();
258 expect(summary).not.toHaveBeenCalled();
259 }),
260 ),
261 );
262 it.effect("fails closed when a host cannot confirm the merge", () =>
263 use(
264 Effect.gen(function* () {
265 summary.mockImplementation(
266 () => new PullRequestOperationError({ operation: "summary", detail: "Host unavailable" }),
267 );
268 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
269 expect((yield* service.archiveCompleted({ workspacePath }).pipe(Effect.flip)).reason).toBe(
270 "archive",
271 );
272 expect(dispatch).not.toHaveBeenCalled();
273 }),
274 ),
275 );
276 it.effect("retains an unknown merge timestamp", () =>
277 use(
278 Effect.gen(function* () {
279 summary.mockImplementation(() =>
280 Effect.succeed({ ...pullRequest("merged"), mergedAt: null }),
281 );
282 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
283 expect(yield* service.archiveCompleted({ workspacePath })).toEqual({
284 archivedThreadIds: [],
285 });
286 expect(dispatch).not.toHaveBeenCalled();
287 }),
288 ),
289 );
290 it.effect("refuses an arbitrary project checkout", () =>
291 use(
292 Effect.gen(function* () {
293 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
294 expect(
295 (yield* service.archiveCompleted({ workspacePath: home }).pipe(Effect.flip)).reason,
296 ).toBe("workspace");
297 expect(dispatch).not.toHaveBeenCalled();
298 }),
299 ),
300 );
301});
302
303it.effect("retains an abandoned settled workspace without a merged PR", () =>
304 use(
305 Effect.gen(function* () {
306 threads[0] = { ...threads[0]!, pullRequests: [] };
307 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
308 expect(yield* service.archiveCompleted({ workspacePath })).toEqual({ archivedThreadIds: [] });
309 expect(launches).not.toHaveBeenCalled();
310 expect(dispatch).not.toHaveBeenCalled();
311 }),
312 ),
313);
314
315it.effect("recognizes managed project roots and managed thread worktrees", () =>
316 use(
317 Effect.gen(function* () {
318 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
319 expect(yield* service.isManagedThread(threadId)).toBe(true);
320 threads[0] = { ...threads[0]!, worktreePath: home };
321 expect(yield* service.isManagedThread(threadId)).toBe(false);
322 threads[0] = { ...threads[0]!, worktreePath: workspacePath };
323 expect(yield* service.isManagedThread(threadId)).toBe(true);
324 expect(yield* service.isManagedThread(ThreadId.make("missing-thread"))).toBe(false);
325 expect(launches).not.toHaveBeenCalled();
326 }),
327 ),
328);
329
330it.effect("keeps merged workspaces available while another chat references them", () =>
331 use(
332 Effect.gen(function* () {
333 threads.push({ ...thread(), id: ThreadId.make("active-peer"), settledOverride: "active" });
334 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
335 expect(yield* service.archiveCompleted({ workspacePath })).toEqual({ archivedThreadIds: [] });
336 expect(dispatch).not.toHaveBeenCalled();
337 expect(summary).not.toHaveBeenCalled();
338 }),
339 ),
340);
apps/server/src/workspace/ManagedWorkspaces.test.ts created+173
...@@ -0,0 +1,173 @@
1// @effect-diagnostics nodeBuiltinImport:off - Owns isolated filesystem fixtures.
2import { afterEach, beforeEach, describe, expect, vi } from "vite-plus/test";
3import { it } from "@effect/vitest";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { ProjectId } from "@t3tools/contracts";
6import * as HostProcess from "@t3tools/shared/HostProcess";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
10import * as NodeFSP from "node:fs/promises";
11import * as NodeOS from "node:os";
12import * as NodePath from "node:path";
13
14import * as ServerConfig from "../config.ts";
15import * as ProcessRunner from "../processRunner.ts";
16import * as ManagedWorkspaces from "./ManagedWorkspaces.ts";
17import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
18import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
19import * as PullRequestService from "../pullRequest/PullRequestService.ts";
20
21let home: string;
22let repository: string;
23let platform: NodeJS.Platform;
24let sourceRegistered: boolean;
25const launches = vi.fn<ProcessRunner.ProcessRunner["Service"]["run"]>();
26const created = {
27 workspacePath: "/test/workspaces/1",
28 projectId: "new-project",
29 threadId: "new-thread",
30 environmentId: "current-host",
31};
32const output = (stdout = JSON.stringify(created), code = 0) => ({
33 stdout,
34 stderr: "",
35 code: ChildProcessSpawner.ExitCode(code),
36 timedOut: false,
37 stdoutTruncated: false,
38 stderrTruncated: false,
39 stdoutInvalidUtf8: false,
40 stderrInvalidUtf8: false,
41});
42
43beforeEach(async () => {
44 home = await NodeFSP.realpath(
45 await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-managed-ws-")),
46 );
47 repository = NodePath.join(home, "source");
48 platform = "darwin";
49 sourceRegistered = true;
50 await NodeFSP.mkdir(repository, { recursive: true });
51 await NodeFSP.mkdir(NodePath.join(home, ".config"));
52 await NodeFSP.mkdir(NodePath.join(home, "bin"));
53 await NodeFSP.writeFile(
54 NodePath.join(home, ".config/workspaces.json"),
55 JSON.stringify({ repository }),
56 );
57 await NodeFSP.writeFile(NodePath.join(home, "bin/ws"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
58 launches.mockReset();
59 launches.mockImplementation(() => Effect.succeed(output()));
60});
61
62afterEach(async () => {
63 await NodeFSP.rm(home, { recursive: true, force: true });
64});
65
66const layer = () =>
67 ManagedWorkspaces.layer.pipe(
68 Layer.provide(
69 Layer.mergeAll(
70 NodeServices.layer,
71 Layer.succeed(HostProcess.HomeDirectory, home),
72 Layer.succeed(HostProcess.Platform, platform),
73 Layer.succeed(HostProcess.Environment, { PATH: NodePath.join(home, "bin") }),
74 ServerConfig.layerTest(repository, NodePath.join(home, "isolated-t3")).pipe(
75 Layer.provide(NodeServices.layer),
76 ),
77 Layer.succeed(ProcessRunner.ProcessRunner, { run: launches }),
78 Layer.mock(Orchestrator.OrchestratorV2)({}),
79 Layer.mock(ProjectStore.ProjectStoreV2)({
80 listShells: () =>
81 Effect.succeed(
82 sourceRegistered
83 ? [
84 {
85 id: ProjectId.make("source-project"),
86 title: "my work",
87 workspaceRoot: repository,
88 defaultModelSelection: null,
89 scripts: [],
90 createdAt: "2026-10-13T12:00:00.000Z",
91 updatedAt: "2026-10-13T12:00:00.000Z",
92 },
93 ]
94 : [],
95 ),
96 }),
97 Layer.mock(PullRequestService.PullRequestService)({}),
98 ),
99 ),
100 );
101
102const use = <A, E>(effect: Effect.Effect<A, E, ManagedWorkspaces.ManagedWorkspaces>) =>
103 effect.pipe(Effect.provide(layer()));
104
105describe("managed workspace creation", () => {
106 it.effect(
107 "reuses the installed workflow and targets this server without opening a host window",
108 () =>
109 use(
110 Effect.gen(function* () {
111 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
112 expect(yield* service.status()).toEqual({ projectId: ProjectId.make("source-project") });
113 expect(launches).not.toHaveBeenCalled();
114 expect(yield* service.create()).toEqual(created);
115 expect(launches).toHaveBeenCalledOnce();
116 expect(launches.mock.calls[0]?.[0]).toMatchObject({
117 command: NodePath.join(home, "bin/ws"),
118 args: ["new", "--background", "--no-open", "--json"],
119 cwd: repository,
120 env: { T3CODE_HOME: NodePath.join(home, "isolated-t3") },
121 });
122 }),
123 ),
124 );
125
126 it.effect.each([
127 "missing tool",
128 "missing configuration",
129 "missing repository",
130 "unregistered source",
131 "Windows",
132 ] as const)("does not launch when %s makes creation unavailable", (reason) => {
133 if (reason === "Windows") platform = "win32";
134 if (reason === "unregistered source") sourceRegistered = false;
135 return use(
136 Effect.gen(function* () {
137 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
138 if (reason === "missing tool")
139 yield* Effect.promise(() => NodeFSP.rm(NodePath.join(home, "bin/ws")));
140 if (reason === "missing configuration")
141 yield* Effect.promise(() => NodeFSP.rm(NodePath.join(home, ".config/workspaces.json")));
142 if (reason === "missing repository")
143 yield* Effect.promise(() => NodeFSP.rm(repository, { recursive: true }));
144 expect(yield* service.status()).toEqual({ projectId: null });
145 const error = yield* service.create().pipe(Effect.flip);
146 expect(error.reason).toBe("unavailable");
147 expect(launches).not.toHaveBeenCalled();
148 }),
149 );
150 });
151
152 it.effect("rejects unreadable and incomplete CLI replies", () =>
153 use(
154 Effect.gen(function* () {
155 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
156 for (const stdout of ["not JSON", JSON.stringify({ workspacePath: "/created" })]) {
157 launches.mockImplementation(() => Effect.succeed(output(stdout)));
158 expect((yield* service.create().pipe(Effect.flip)).reason).toBe("reply");
159 }
160 }),
161 ),
162 );
163
164 it.effect("propagates a stopped creation instead of navigating to an incomplete workspace", () =>
165 use(
166 Effect.gen(function* () {
167 launches.mockImplementation(() => Effect.succeed(output("", 1)));
168 const service = yield* ManagedWorkspaces.ManagedWorkspaces;
169 expect((yield* service.create().pipe(Effect.flip)).reason).toBe("create");
170 }),
171 ),
172 );
173});
apps/server/src/workspace/ManagedWorkspaces.ts created+223
...@@ -0,0 +1,223 @@
1import {
2 CommandId,
3 type ProjectId,
4 type ThreadId,
5 type ManagedWorkspaceArchived,
6 ManagedWorkspaceCreated,
7 ManagedWorkspaceError,
8} from "@t3tools/contracts";
9import { resolveCommandPath } from "@t3tools/shared/shell";
10import * as HostProcess from "@t3tools/shared/HostProcess";
11import * as Context from "effect/Context";
12import * as Crypto from "effect/Crypto";
13import * as Option from "effect/Option";
14import * as Effect from "effect/Effect";
15import * as FileSystem from "effect/FileSystem";
16import * as Layer from "effect/Layer";
17import * as Path from "effect/Path";
18import * as Schema from "effect/Schema";
19
20import * as ServerConfig from "../config.ts";
21import * as ProcessRunner from "../processRunner.ts";
22import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
23import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
24import * as PullRequestService from "../pullRequest/PullRequestService.ts";
25import {
26 normalizeThreadPullRequestKey,
27 visibleThreadPullRequests,
28} from "@t3tools/shared/threadPullRequests";
29import { isManagedWorkspaceArchiveCandidate } from "./managedWorkspaceArchive.ts";
30
31const decodeConfiguration = Schema.decodeUnknownEffect(
32 Schema.fromJsonString(Schema.Struct({ repository: Schema.String })),
33);
34const decodeCreated = Schema.decodeUnknownEffect(Schema.fromJsonString(ManagedWorkspaceCreated));
35
36export class ManagedWorkspaces extends Context.Service<
37 ManagedWorkspaces,
38 {
39 readonly isManagedThread: (threadId: ThreadId) => Effect.Effect<boolean, ManagedWorkspaceError>;
40 readonly status: () => Effect.Effect<{ readonly projectId: ProjectId | null }>;
41 readonly create: () => Effect.Effect<ManagedWorkspaceCreated, ManagedWorkspaceError>;
42 readonly archiveCompleted: (input: {
43 readonly workspacePath: string;
44 }) => Effect.Effect<ManagedWorkspaceArchived, ManagedWorkspaceError>;
45 }
46>()("t3/workspace/ManagedWorkspaces") {}
47
48const make = Effect.gen(function* () {
49 const fs = yield* FileSystem.FileSystem;
50 const path = yield* Path.Path;
51 const home = yield* HostProcess.HomeDirectory;
52 const platform = yield* HostProcess.Platform;
53 const environment = yield* HostProcess.Environment;
54 const config = yield* ServerConfig.ServerConfig;
55 const runner = yield* ProcessRunner.ProcessRunner;
56 const orchestrator = yield* Orchestrator.OrchestratorV2;
57 const projects = yield* ProjectStore.ProjectStoreV2;
58 const pullRequests = yield* PullRequestService.PullRequestService;
59 const crypto = yield* Crypto.Crypto;
60
61 const configuration = Effect.gen(function* () {
62 if (platform === "win32") return null;
63 const value = yield* fs.readFileString(path.join(home, ".config/workspaces.json"));
64 const configured = yield* decodeConfiguration(value);
65 const repository = yield* fs.realPath(configured.repository);
66 const executable = yield* resolveCommandPath("ws", { env: environment }).pipe(
67 Effect.provideService(FileSystem.FileSystem, fs),
68 Effect.provideService(Path.Path, path),
69 Effect.provideService(HostProcess.Platform, platform),
70 );
71 if (executable === null) return null;
72 return { repository, executable };
73 }).pipe(Effect.orElseSucceed(() => null));
74
75 const status = () =>
76 Effect.gen(function* () {
77 const configured = yield* configuration;
78 if (configured === null) return { projectId: null };
79 const sources = yield* projects.listShells();
80 for (const project of sources) {
81 const root = yield* fs
82 .realPath(project.workspaceRoot)
83 .pipe(Effect.orElseSucceed(() => null));
84 if (root === configured.repository) return { projectId: project.id };
85 }
86 return { projectId: null };
87 }).pipe(Effect.orElseSucceed(() => ({ projectId: null })));
88
89 const create = Effect.fn("ManagedWorkspaces.create")(function* () {
90 const configured = yield* configuration;
91 if (configured === null || (yield* status()).projectId === null)
92 return yield* new ManagedWorkspaceError({ reason: "unavailable" });
93 const result = yield* runner
94 .run({
95 command: configured.executable,
96 args: ["new", "--background", "--no-open", "--json"],
97 cwd: configured.repository,
98 env: { T3CODE_HOME: config.baseDir },
99 timeout: "30 seconds",
100 maxOutputBytes: 16 * 1024,
101 })
102 .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "create", cause })));
103 if (result.code !== 0 || result.timedOut) {
104 return yield* new ManagedWorkspaceError({ reason: "create" });
105 }
106 return yield* decodeCreated(result.stdout).pipe(
107 Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "reply", cause })),
108 );
109 });
110
111 const managedDirectory = Effect.fn("ManagedWorkspaces.managedDirectory")(function* (
112 directory: string,
113 ) {
114 const root = path.join(home, "workspaces");
115 const id = path.relative(root, directory);
116 if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(id)) return null;
117 const workspacePath = yield* fs
118 .realPath(directory)
119 .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "workspace", cause })));
120 if (
121 workspacePath !== path.join(root, id) ||
122 !(yield* fs
123 .exists(path.join(home, ".local/state/workspaces/workspaces", `${id}.json`))
124 .pipe(
125 Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "workspace", cause })),
126 ))
127 ) {
128 return null;
129 }
130 return { id, workspacePath };
131 });
132
133 const isManagedThread = Effect.fn("ManagedWorkspaces.isManagedThread")(function* (
134 threadId: ThreadId,
135 ) {
136 const thread = yield* orchestrator
137 .getThreadShell(threadId)
138 .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "workspace", cause })));
139 if (thread === null) return false;
140 const project = yield* projects
141 .getShell(thread.projectId)
142 .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "workspace", cause })));
143 if (Option.isNone(project)) return false;
144 return (yield* managedDirectory(thread.worktreePath ?? project.value.workspaceRoot)) !== null;
145 });
146
147 const archiveCompleted = Effect.fn("ManagedWorkspaces.archiveCompleted")(function* (input: {
148 readonly workspacePath: string;
149 }) {
150 const managed = yield* managedDirectory(input.workspacePath);
151 if (managed === null) return yield* new ManagedWorkspaceError({ reason: "workspace" });
152 const { workspacePath } = managed;
153 const configured = yield* configuration;
154 if (configured === null) return yield* new ManagedWorkspaceError({ reason: "unavailable" });
155 const [snapshot, projectShells] = yield* Effect.all([
156 orchestrator.getShellSnapshot({ location: "active", unsettledOnly: false }),
157 projects.listShells(),
158 ]).pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause })));
159 const roots = new Map(projectShells.map((project) => [project.id, project.workspaceRoot]));
160 const threads = [];
161 for (const thread of snapshot.threads) {
162 const cwd = thread.worktreePath ?? roots.get(thread.projectId);
163 if (cwd === undefined) return yield* new ManagedWorkspaceError({ reason: "workspace" });
164 const relative = path.relative(workspacePath, cwd);
165 if (
166 relative === "" ||
167 (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
168 )
169 threads.push(thread);
170 }
171 const candidates = threads.filter(isManagedWorkspaceArchiveCandidate);
172 if (candidates.length === 0 || candidates.length !== threads.length)
173 return { archivedThreadIds: [] };
174 for (const thread of candidates) {
175 const links = visibleThreadPullRequests(thread.pullRequests ?? []);
176 if (links.length === 0) return { archivedThreadIds: [] };
177 for (const link of links) {
178 const ref = {
179 ...normalizeThreadPullRequestKey(link),
180 projectId: thread.projectId,
181 allowStale: false,
182 };
183 yield* pullRequests
184 .invalidate({ reference: ref })
185 .pipe(
186 Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause })),
187 );
188 const summary = yield* pullRequests
189 .summary(ref, { recoverTransientFailure: false })
190 .pipe(
191 Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause })),
192 );
193 if (summary.state !== "merged" || !Number.isFinite(Date.parse(summary.mergedAt ?? "")))
194 return { archivedThreadIds: [] };
195 }
196 }
197 const archivedThreadIds: ThreadId[] = [];
198 for (const thread of candidates) {
199 const uuid = yield* crypto
200 .randomUUIDv4()
201 .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause })));
202 yield* orchestrator
203 .dispatch({
204 type: "thread.archive",
205 commandId: CommandId.make(`managed-workspace:${uuid}`),
206 threadId: thread.id,
207 snapshotSequence: snapshot.snapshotSequence,
208 })
209 .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause })));
210 archivedThreadIds.push(thread.id);
211 }
212 return { archivedThreadIds };
213 });
214
215 return {
216 status,
217 create,
218 archiveCompleted,
219 isManagedThread,
220 } satisfies ManagedWorkspaces["Service"];
221});
222
223export const layer = Layer.effect(ManagedWorkspaces, make);
apps/server/src/workspace/managedWorkspaceArchive.ts created+20
...@@ -0,0 +1,20 @@
1import type { OrchestrationV2ThreadShell } from "@t3tools/contracts";
2
3export function isManagedWorkspaceArchiveCandidate(
4 thread: OrchestrationV2ThreadShell,
5): thread is OrchestrationV2ThreadShell & {
6 readonly settledAt: NonNullable<OrchestrationV2ThreadShell["settledAt"]>;
7} {
8 return (
9 thread.settledOverride === "settled" &&
10 thread.settledAt !== null &&
11 thread.archivedAt === null &&
12 thread.deletedAt === null &&
13 thread.activeRunId === null &&
14 ["idle", "completed", "interrupted", "failed", "cancelled", "rolled_back"].includes(
15 thread.status,
16 ) &&
17 thread.pendingRuntimeRequest === null &&
18 (thread.pendingBackgroundTasks?.length ?? 0) === 0
19 );
20}
apps/server/src/ws.ts+10-4
...@@ -8,14 +8,12 @@ import * as DateTime from "effect/DateTime";...@@ -8,14 +8,12 @@ import * as DateTime from "effect/DateTime";
8import * as Duration from "effect/Duration";8import * as Duration from "effect/Duration";
9import * as Base64 from "effect/encoding/Base64";9import * as Base64 from "effect/encoding/Base64";
10import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as FileSystem from "effect/FileSystem";11import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";13import * as Option from "effect/Option";
15import * as Path from "effect/Path";14import * as Path from "effect/Path";
16import * as Queue from "effect/Queue";15import * as Queue from "effect/Queue";
17import * as Ref from "effect/Ref";16import * as Ref from "effect/Ref";
18import * as Schedule from "effect/Schedule";
19import * as Schema from "effect/Schema";17import * as Schema from "effect/Schema";
20import * as Result from "effect/Result";18import * as Result from "effect/Result";
21import * as Stream from "effect/Stream";19import * as Stream from "effect/Stream";
...@@ -191,6 +189,7 @@ import * as PortScanner from "./preview/PortScanner.ts";...@@ -191,6 +189,7 @@ import * as PortScanner from "./preview/PortScanner.ts";
191import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";189import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
192import * as MergebotQueue from "./mergebot/MergebotQueue.ts";190import * as MergebotQueue from "./mergebot/MergebotQueue.ts";
193import * as DevServer from "./workspace/DevServer.ts";191import * as DevServer from "./workspace/DevServer.ts";
192import * as ManagedWorkspaces from "./workspace/ManagedWorkspaces.ts";
194import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";193import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
195import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts";194import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts";
196import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";195import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
...@@ -204,7 +203,6 @@ import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts...@@ -204,7 +203,6 @@ import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts
204import * as ProjectService from "./project/ProjectService.ts";203import * as ProjectService from "./project/ProjectService.ts";
205import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts";204import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts";
206import { projectMutationOperation } from "./project/ProjectMutation.ts";205import { projectMutationOperation } from "./project/ProjectMutation.ts";
207import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
208import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";206import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
209import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";207import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
210import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";208import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";
...@@ -1197,7 +1195,6 @@ const layerWsRpc = (...@@ -1197,7 +1195,6 @@ const layerWsRpc = (
1197 );1195 );
1198 const usage = yield* UsageService.UsageService;1196 const usage = yield* UsageService.UsageService;
1199 const usageLimitSources = yield* UsageLimitSources.UsageLimitSources;1197 const usageLimitSources = yield* UsageLimitSources.UsageLimitSources;
1200 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
1201 const worktreeSetupTracker = yield* WorktreeSetupTracker.WorktreeSetupTracker;1198 const worktreeSetupTracker = yield* WorktreeSetupTracker.WorktreeSetupTracker;
1202 const projectCloneTracker = yield* ProjectCloneTracker.ProjectCloneTracker;1199 const projectCloneTracker = yield* ProjectCloneTracker.ProjectCloneTracker;
1203 const repositoryIdentityResolver =1200 const repositoryIdentityResolver =
...@@ -1236,6 +1233,7 @@ const layerWsRpc = (...@@ -1236,6 +1233,7 @@ const layerWsRpc = (
1236 const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup;1233 const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup;
1237 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;1234 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
1238 const devServer = yield* DevServer.DevServer;1235 const devServer = yield* DevServer.DevServer;
1236 const managedWorkspaces = yield* ManagedWorkspaces.ManagedWorkspaces;
1239 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;1237 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
1240 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;1238 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
1241 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;1239 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
...@@ -1678,6 +1676,7 @@ const layerWsRpc = (...@@ -1678,6 +1676,7 @@ const layerWsRpc = (
1678 },1676 },
1679 settings,1677 settings,
1680 devServerStatus: true,1678 devServerStatus: true,
1679 managedWorkspaces: true,
1681 ...(mergebotQueue.configuration ? { mergebot: mergebotQueue.configuration } : {}),1680 ...(mergebotQueue.configuration ? { mergebot: mergebotQueue.configuration } : {}),
1682 shellResumeCompletionMarker: true,1681 shellResumeCompletionMarker: true,
1683 threadResumeCompletionMarker: true,1682 threadResumeCompletionMarker: true,
...@@ -1809,6 +1808,9 @@ const layerWsRpc = (...@@ -1809,6 +1808,9 @@ const layerWsRpc = (
1809 return new OrchestrationV2DispatchCommandError({1808 return new OrchestrationV2DispatchCommandError({
1810 commandId: command.commandId,1809 commandId: command.commandId,
1811 commandType: command.type,1810 commandType: command.type,
1811 ...(cause._tag === "OrchestratorDispatchError" && cause.reason !== undefined
1812 ? { reason: cause.reason }
1813 : {}),
1812 message: detail ?? "Failed to dispatch orchestration V2 command",1814 message: detail ?? "Failed to dispatch orchestration V2 command",
1813 ...(detail === undefined ? {} : { detail }),1815 ...(detail === undefined ? {} : { detail }),
1814 cause,1816 cause,
...@@ -2604,6 +2606,10 @@ const layerWsRpc = (...@@ -2604,6 +2606,10 @@ const layerWsRpc = (
2604 }),2606 }),
2605 ),2607 ),
2606 ),2608 ),
2609 [WS_METHODS.managedWorkspacesStatus]: () => managedWorkspaces.status(),
2610 [WS_METHODS.managedWorkspacesCreate]: () => managedWorkspaces.create(),
2611 [WS_METHODS.managedWorkspacesArchiveCompleted]: (input) =>
2612 managedWorkspaces.archiveCompleted(input),
2607 [WS_METHODS.devServerStatus]: (input) => devServer.status(input),2613 [WS_METHODS.devServerStatus]: (input) => devServer.status(input),
2608 [WS_METHODS.devServerStart]: (input) => devServer.start(input),2614 [WS_METHODS.devServerStart]: (input) => devServer.start(input),
2609 [WS_METHODS.projectsReadFile]: (input) =>2615 [WS_METHODS.projectsReadFile]: (input) =>
apps/web/src/components/AppSidebarLayout.tsx+6
...@@ -35,6 +35,7 @@ import LegacyThreadSidebar from "./LegacySidebar";...@@ -35,6 +35,7 @@ import LegacyThreadSidebar from "./LegacySidebar";
35import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration";35import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration";
36import ThreadSidebar from "./Sidebar";36import ThreadSidebar from "./Sidebar";
37import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";37import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
38import { NewWorkWorkspaceButton } from "./sidebar/NewWorkWorkspaceButton";
38import { AppMenu } from "./sidebar/AppMenu";39import { AppMenu } from "./sidebar/AppMenu";
39import { SidebarChromeHeader } from "./sidebar/SidebarChrome";40import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
40import { MainAppLocationTracker } from "./sidebar/mainAppLocation";41import { MainAppLocationTracker } from "./sidebar/mainAppLocation";
...@@ -155,6 +156,11 @@ function SidebarControl() {...@@ -155,6 +156,11 @@ function SidebarControl() {
155 Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""}156 Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""}
156 </TooltipPopup>157 </TooltipPopup>
157 </Tooltip>158 </Tooltip>
159 {isSidebarVisible ? (
160 <div className="pointer-events-auto ml-2 [-webkit-app-region:no-drag]">
161 <NewWorkWorkspaceButton />
162 </div>
163 ) : null}
158 </div>164 </div>
159 );165 );
160}166}
apps/web/src/components/CommandPalette.tsx+13
...@@ -119,6 +119,7 @@ import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";...@@ -119,6 +119,7 @@ import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
119import { sourceControlEnvironment } from "../state/sourceControl";119import { sourceControlEnvironment } from "../state/sourceControl";
120import { useAtomCommand } from "../state/use-atom-command";120import { useAtomCommand } from "../state/use-atom-command";
121import { useAtomQueryRunner } from "../state/use-atom-query-runner";121import { useAtomQueryRunner } from "../state/use-atom-query-runner";
122import { useNewWorkWorkspace } from "../hooks/useNewWorkWorkspace";
122import { useScratchProject } from "../hooks/useScratchProject";123import { useScratchProject } from "../hooks/useScratchProject";
123import { isScratchProject } from "@t3tools/client-runtime/state/projects";124import { isScratchProject } from "@t3tools/client-runtime/state/projects";
124import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";125import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
...@@ -621,6 +622,7 @@ function OpenCommandPaletteDialog(props: {...@@ -621,6 +622,7 @@ function OpenCommandPaletteDialog(props: {
621 const { environments } = useEnvironments();622 const { environments } = useEnvironments();
622 const desktopLocalBootstraps = useDesktopLocalBootstraps();623 const desktopLocalBootstraps = useDesktopLocalBootstraps();
623 const primaryEnvironmentId = usePrimaryEnvironmentId();624 const primaryEnvironmentId = usePrimaryEnvironmentId();
625 const newWorkWorkspace = useNewWorkWorkspace();
624 const availableSettingsSearchItems = useAvailableSettingsSearchItems();626 const availableSettingsSearchItems = useAvailableSettingsSearchItems();
625 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =627 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
626 useHandleNewThread();628 useHandleNewThread();
...@@ -1774,6 +1776,17 @@ function OpenCommandPaletteDialog(props: {...@@ -1774,6 +1776,17 @@ function OpenCommandPaletteDialog(props: {
1774 }, [clearOpenIntent, browseNavigation, openIntent, projectThreadItems, pushPaletteView]);1776 }, [clearOpenIntent, browseNavigation, openIntent, projectThreadItems, pushPaletteView]);
17751777
1776 const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [];1778 const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [];
1779 if (newWorkWorkspace.project) {
1780 actionItems.push({
1781 kind: "action",
1782 value: "action:new-work-workspace",
1783 searchTerms: ["my work", "workspace", "new", "create"],
1784 title: newWorkWorkspace.label,
1785 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
1786 disabled: newWorkWorkspace.disabled,
1787 run: newWorkWorkspace.run,
1788 });
1789 }
17771790
1778 if (projects.length > 0) {1791 if (projects.length > 0) {
1779 const activeProjectTitle =1792 const activeProjectTitle =
apps/web/src/components/Sidebar.logic.test.ts+78-3
...@@ -60,7 +60,15 @@ import {...@@ -60,7 +60,15 @@ import {
60 resolveSidebarDropVerb,60 resolveSidebarDropVerb,
61} from "./Sidebar.logic";61} from "./Sidebar.logic";
62import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";62import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
63import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts";63import {
64 CommandId,
65 EnvironmentId,
66 OrchestrationV2DispatchCommandError,
67 ProjectId,
68 ProviderInstanceId,
69 RunId,
70 ThreadId,
71} from "@t3tools/contracts";
6472
65import {73import {
66 DEFAULT_INTERACTION_MODE,74 DEFAULT_INTERACTION_MODE,
...@@ -242,8 +250,8 @@ describe("deleteSelectedThreadEntries", () => {...@@ -242,8 +250,8 @@ describe("deleteSelectedThreadEntries", () => {
242250
243describe("archiveSelectedThreadEntries", () => {251describe("archiveSelectedThreadEntries", () => {
244 const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const;252 const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const;
245 const success = { _tag: "Success" } as const;253 const success = AsyncResult.success(undefined);
246 const failure = { _tag: "Failure" } as const;254 const failure = AsyncResult.failure(Cause.fail(new Error("Archive failed")));
247255
248 it("records every entry after full success", async () => {256 it("records every entry after full success", async () => {
249 const outcome = await archiveSelectedThreadEntries({257 const outcome = await archiveSelectedThreadEntries({
...@@ -291,6 +299,73 @@ describe("archiveSelectedThreadEntries", () => {...@@ -291,6 +299,73 @@ describe("archiveSelectedThreadEntries", () => {
291 followupFailures: [failure],299 followupFailures: [failure],
292 });300 });
293 });301 });
302
303 it("archives a full shelf beyond the visible tail and omits a revived entry", async () => {
304 const entries = Array.from({ length: 137 }, (_, index) => ({ threadKey: `settled-${index}` }));
305 const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => {
306 if (entry.threadKey === "settled-92") return null;
307 onArchived();
308 return success;
309 });
310 const outcome = await archiveSelectedThreadEntries({ entries, archive });
311
312 expect(archive).toHaveBeenCalledTimes(137);
313 expect(outcome.archivedThreadKeys).toHaveLength(136);
314 expect(outcome.archivedThreadKeys).toContain("settled-136");
315 expect(outcome.archivedThreadKeys).not.toContain("settled-92");
316 expect(outcome.mutationFailure).toBeNull();
317 expect(outcome.followupFailures).toEqual([]);
318 });
319 it("skips a typed condition rejection in the middle and archives later candidates", async () => {
320 const changed = AsyncResult.failure(
321 Cause.fail(
322 new OrchestrationV2DispatchCommandError({
323 commandId: CommandId.make("archive-two"),
324 commandType: "thread.archive",
325 reason: "archive-condition-changed",
326 message: "The thread changed before automatic archival.",
327 }),
328 ),
329 );
330 const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => {
331 if (entry.threadKey === "two") return changed;
332 onArchived();
333 return success;
334 });
335 const outcome = await archiveSelectedThreadEntries({ entries, archive });
336
337 expect(archive).toHaveBeenCalledTimes(3);
338 expect(outcome).toEqual({
339 archivedThreadKeys: ["one", "three"],
340 mutationFailure: null,
341 followupFailures: [],
342 });
343 });
344
345 it("does not skip a genuine failure with the condition error's text", async () => {
346 const rejected = AsyncResult.failure(
347 Cause.fail(
348 new OrchestrationV2DispatchCommandError({
349 commandId: CommandId.make("archive-two"),
350 commandType: "thread.archive",
351 message: "The thread changed before automatic archival.",
352 }),
353 ),
354 );
355 const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => {
356 if (entry.threadKey === "two") return rejected;
357 onArchived();
358 return success;
359 });
360 const outcome = await archiveSelectedThreadEntries({ entries, archive });
361
362 expect(archive).toHaveBeenCalledTimes(2);
363 expect(outcome).toEqual({
364 archivedThreadKeys: ["one"],
365 mutationFailure: rejected,
366 followupFailures: [],
367 });
368 });
294});369});
295370
296describe("buildBulkUnpinContextMenuItem", () => {371describe("buildBulkUnpinContextMenuItem", () => {
apps/web/src/components/Sidebar.logic.ts+24-9
...@@ -8,7 +8,14 @@ import {...@@ -8,7 +8,14 @@ import {
8} from "@t3tools/client-runtime/state/runtime";8} from "@t3tools/client-runtime/state/runtime";
9import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";9import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";
10import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";10import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
11import type { ContextMenuItem, EnvironmentId, ThreadId } from "@t3tools/contracts";11import {
12 OrchestrationV2DispatchCommandError,
13 type ContextMenuItem,
14 type EnvironmentId,
15 type ThreadId,
16} from "@t3tools/contracts";
17import * as Cause from "effect/Cause";
18import * as Schema from "effect/Schema";
12import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";19import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
13import type { AsyncResult } from "effect/reactivity";20import type { AsyncResult } from "effect/reactivity";
14import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";21import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";
...@@ -516,33 +523,41 @@ export async function deleteSelectedThreadEntries<...@@ -516,33 +523,41 @@ export async function deleteSelectedThreadEntries<
516 return { deletedThreadKeys, firstFailure };523 return { deletedThreadKeys, firstFailure };
517}524}
518525
526const isDispatchCommandError = Schema.is(OrchestrationV2DispatchCommandError);
527
519export async function archiveSelectedThreadEntries<528export async function archiveSelectedThreadEntries<
520 TEntry extends { readonly threadKey: string },529 TEntry extends { readonly threadKey: string },
521 TResult extends { readonly _tag: "Success" | "Failure" },
522>(input: {530>(input: {
523 entries: readonly TEntry[];531 entries: readonly TEntry[];
524 archive: (entry: TEntry, onArchived: () => void) => Promise<TResult>;532 archive: (
533 entry: TEntry,
534 onArchived: () => void,
535 ) => Promise<AtomCommandResult<unknown, unknown> | null>;
525}): Promise<{536}): Promise<{
526 archivedThreadKeys: readonly string[];537 archivedThreadKeys: readonly string[];
527 mutationFailure: Extract<TResult, { readonly _tag: "Failure" }> | null;538 mutationFailure: AsyncResult.Failure<unknown, unknown> | null;
528 followupFailures: readonly Extract<TResult, { readonly _tag: "Failure" }>[];539 followupFailures: readonly AsyncResult.Failure<unknown, unknown>[];
529}> {540}> {
530 const archivedThreadKeys: string[] = [];541 const archivedThreadKeys: string[] = [];
531 const followupFailures: Extract<TResult, { readonly _tag: "Failure" }>[] = [];542 const followupFailures: AsyncResult.Failure<unknown, unknown>[] = [];
532543
533 for (const entry of input.entries) {544 for (const entry of input.entries) {
534 let didArchive = false;545 let didArchive = false;
535 const result = await input.archive(entry, () => {546 const result = await input.archive(entry, () => {
536 didArchive = true;547 didArchive = true;
537 });548 });
549 if (result === null) continue;
538 if (didArchive || result._tag === "Success") archivedThreadKeys.push(entry.threadKey);550 if (didArchive || result._tag === "Success") archivedThreadKeys.push(entry.threadKey);
539 if (result._tag === "Success") continue;551 if (result._tag === "Success") continue;
540 const failure = result as Extract<TResult, { readonly _tag: "Failure" }>;
541 if (didArchive) {552 if (didArchive) {
542 followupFailures.push(failure);553 followupFailures.push(result);
554 continue;
555 }
556 const error = Cause.squash(result.cause);
557 if (isDispatchCommandError(error) && error.reason === "archive-condition-changed") {
543 continue;558 continue;
544 }559 }
545 return { archivedThreadKeys, mutationFailure: failure, followupFailures };560 return { archivedThreadKeys, mutationFailure: result, followupFailures };
546 }561 }
547562
548 return { archivedThreadKeys, mutationFailure: null, followupFailures };563 return { archivedThreadKeys, mutationFailure: null, followupFailures };
apps/web/src/components/Sidebar.tsx+188-47
...@@ -172,6 +172,8 @@ import {...@@ -172,6 +172,8 @@ import {
172import { environmentProjects } from "../state/projects";172import { environmentProjects } from "../state/projects";
173import { vcsEnvironment } from "../state/vcs";173import { vcsEnvironment } from "../state/vcs";
174import { threadEnvironment } from "../state/threads";174import { threadEnvironment } from "../state/threads";
175import { appAtomRegistry } from "../rpc/atomRegistry";
176import { environmentSnapshotAtom } from "../state/shell";
175import { useEnvironmentQuery } from "../state/query";177import { useEnvironmentQuery } from "../state/query";
176import { useThreadSearch } from "../state/queries";178import { useThreadSearch } from "../state/queries";
177import { useOrchestrationCommand } from "../state/use-orchestration-command";179import { useOrchestrationCommand } from "../state/use-orchestration-command";
...@@ -201,10 +203,12 @@ import {...@@ -201,10 +203,12 @@ import {
201} from "./threadActionMenu.logic";203} from "./threadActionMenu.logic";
202import {204import {
203 animateSidebarLayoutChanges,205 animateSidebarLayoutChanges,
206 archiveSelectedThreadEntries,
204 applySidebarThreadDrop,207 applySidebarThreadDrop,
205 filterSidebarV2VisibleThreads,208 filterSidebarV2VisibleThreads,
206 buildBulkTitleRegenerationContextMenuItem,209 buildBulkTitleRegenerationContextMenuItem,
207 buildBulkUnpinContextMenuItem,210 buildBulkUnpinContextMenuItem,
211 buildMultiSelectThreadContextMenuItems,
208 deleteSelectedThreadEntries,212 deleteSelectedThreadEntries,
209 filterSidebarProjectScopeItems,213 filterSidebarProjectScopeItems,
210 formatWorkingDurationLabel,214 formatWorkingDurationLabel,
...@@ -305,15 +309,12 @@ import {...@@ -305,15 +309,12 @@ import {
305 type DraftSessionState,309 type DraftSessionState,
306} from "../composerDraftStore";310} from "../composerDraftStore";
307311
308// Settled-tail paging: recent history is the common lookup; the deep tail
309// stays behind an explicit Show more.
310const EMPTY_PROVIDER_ENTRIES: ReadonlyMap<string, ProviderInstanceEntry> = new Map();312const EMPTY_PROVIDER_ENTRIES: ReadonlyMap<string, ProviderInstanceEntry> = new Map();
311// Collapsed shelves share one empty list so a route change alone does not313// Collapsed shelves share one empty list so a route change alone does not
312// give the sidebar list a new identity.314// give the sidebar list a new identity.
313const EMPTY_THREADS: readonly EnvironmentThreadShell[] = [];315const EMPTY_THREADS: readonly EnvironmentThreadShell[] = [];
314316
315const SETTLED_TAIL_INITIAL_COUNT = 10;317const SETTLED_TAIL_INITIAL_COUNT = 10;
316const SETTLED_TAIL_PAGE_COUNT = 25;
317// Fresh keys deliberately reset both shelves to collapsed for existing users.318// Fresh keys deliberately reset both shelves to collapsed for existing users.
318const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded";319const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded";
319const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded";320const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded";
...@@ -818,6 +819,7 @@ function SidebarSectionHeader(props: {...@@ -818,6 +819,7 @@ function SidebarSectionHeader(props: {
818 dragging?: boolean;819 dragging?: boolean;
819 isDropTarget?: boolean;820 isDropTarget?: boolean;
820 toggle: { expanded: boolean; onToggle: () => void };821 toggle: { expanded: boolean; onToggle: () => void };
822 onContextMenu?: (position: { x: number; y: number }) => void;
821}) {823}) {
822 const shelf =824 const shelf =
823 props.marker === "working-header"825 props.marker === "working-header"
...@@ -834,6 +836,22 @@ function SidebarSectionHeader(props: {...@@ -834,6 +836,22 @@ function SidebarSectionHeader(props: {
834 >836 >
835 <CollapsibleSectionHeader837 <CollapsibleSectionHeader
836 onClick={props.toggle.onToggle}838 onClick={props.toggle.onToggle}
839 onContextMenu={(event) => {
840 if (!props.onContextMenu) return;
841 event.preventDefault();
842 props.onContextMenu({ x: event.clientX, y: event.clientY });
843 }}
844 onKeyDown={(event) => {
845 if (
846 !props.onContextMenu ||
847 (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10"))
848 ) {
849 return;
850 }
851 event.preventDefault();
852 const rect = event.currentTarget.getBoundingClientRect();
853 props.onContextMenu({ x: rect.left, y: rect.bottom });
854 }}
837 expanded={props.toggle.expanded}855 expanded={props.toggle.expanded}
838 tone={856 tone={
839 props.isDropTarget ? "accent" : props.dragging ? "emphasized" : snoozed ? "info" : "muted"857 props.isDropTarget ? "accent" : props.dragging ? "emphasized" : snoozed ? "info" : "muted"
...@@ -2454,6 +2472,7 @@ export default function Sidebar() {...@@ -2454,6 +2472,7 @@ export default function Sidebar() {
2454 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);2472 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
2455 const {2473 const {
2456 settleThread,2474 settleThread,
2475 confirmThreadSettlement,
2457 unsettleThread,2476 unsettleThread,
2458 snoozeThread,2477 snoozeThread,
2459 unsnoozeThread,2478 unsnoozeThread,
...@@ -2467,6 +2486,7 @@ export default function Sidebar() {...@@ -2467,6 +2486,7 @@ export default function Sidebar() {
2467 archiveThread,2486 archiveThread,
2468 deleteThread,2487 deleteThread,
2469 } = useThreadActions();2488 } = useThreadActions();
2489 const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);
2470 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {2490 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
2471 reportFailure: false,2491 reportFailure: false,
2472 });2492 });
...@@ -3009,10 +3029,7 @@ export default function Sidebar() {...@@ -3009,10 +3029,7 @@ export default function Sidebar() {
3009 return () => window.clearTimeout(id);3029 return () => window.clearTimeout(id);
3010 }, [snoozedThreads]);3030 }, [snoozedThreads]);
30113031
3012 // The settled tail renders in pages: history shouldn't dominate the3032 // Changing project scope restores the recent tail; the open thread stays visible.
3013 // sidebar, and the common lookups are recent. Expansion resets when the
3014 // filter context changes so a scope/search flip never inherits a deep
3015 // page state.
3016 const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT);3033 const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT);
3017 const settledResetKey = projectScopeKey ?? "all";3034 const settledResetKey = projectScopeKey ?? "all";
3018 const lastSettledResetKeyRef = useRef(settledResetKey);3035 const lastSettledResetKeyRef = useRef(settledResetKey);
...@@ -3039,9 +3056,90 @@ export default function Sidebar() {...@@ -3039,9 +3056,90 @@ export default function Sidebar() {
3039 }, [routeThreadKey, settledThreads, settledVisibleCount]);3056 }, [routeThreadKey, settledThreads, settledVisibleCount]);
3040 const hiddenSettledCount = settledThreads.length - visibleSettledThreads.length;3057 const hiddenSettledCount = settledThreads.length - visibleSettledThreads.length;
3041 const showMoreSettled = useCallback(3058 const showMoreSettled = useCallback(
3042 () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT),3059 () => setSettledVisibleCount(settledThreads.length),
3043 [],3060 [settledThreads.length],
3044 );3061 );
3062 const [archivingThreads, setArchivingThreads] = useState(false);
3063 const archiveThreads = useCallback(
3064 async (targets: readonly EnvironmentThreadShell[], onlySettled = false) => {
3065 if (archivingThreads || targets.length === 0 || !checkThreadOperations(targets)) return;
3066 const entries = targets.map((thread) => {
3067 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
3068 return { threadRef, threadKey: scopedThreadKey(threadRef) };
3069 });
3070 setArchivingThreads(true);
3071 try {
3072 if (confirmThreadArchive) {
3073 const api = readLocalApi();
3074 if (!api) return;
3075 const confirmation = await settlePromise(() =>
3076 api.dialogs.confirm(
3077 `Archive ${entries.length}${onlySettled ? " settled" : ""} thread${entries.length === 1 ? "" : "s"}?`,
3078 ),
3079 );
3080 if (confirmation._tag === "Failure" || !confirmation.value) return;
3081 }
3082 if (!checkThreadOperations(targets)) return;
3083 const outcome = await archiveSelectedThreadEntries({
3084 entries,
3085 archive: ({ threadRef }, onArchived) => {
3086 const snapshot = appAtomRegistry.get(environmentSnapshotAtom(threadRef.environmentId));
3087 const current = snapshot?.threads.find((thread) => thread.id === threadRef.threadId);
3088 if (
3089 snapshot === null ||
3090 current === undefined ||
3091 current.archivedAt !== null ||
3092 (onlySettled && current.settledOverride !== "settled")
3093 ) {
3094 return Promise.resolve(null);
3095 }
3096 return archiveThread(threadRef, {
3097 onArchived,
3098 ...(onlySettled ? { snapshotSequence: snapshot.snapshotSequence } : {}),
3099 });
3100 },
3101 });
3102 removeFromSelection(outcome.archivedThreadKeys);
3103 for (const failure of [outcome.mutationFailure, ...outcome.followupFailures]) {
3104 if (failure === null || isAtomCommandInterrupted(failure)) continue;
3105 const error = squashAtomCommandFailure(failure);
3106 toastManager.add(
3107 stackedThreadToast({
3108 type: "error",
3109 title:
3110 failure === outcome.mutationFailure
3111 ? "Failed to archive threads"
3112 : "Thread archived, but navigation failed",
3113 description: error instanceof Error ? error.message : "An error occurred.",
3114 }),
3115 );
3116 }
3117 } finally {
3118 setArchivingThreads(false);
3119 }
3120 },
3121 [archiveThread, archivingThreads, confirmThreadArchive, removeFromSelection],
3122 );
3123 const handleSettledContextMenu = async (position: { x: number; y: number }) => {
3124 const api = readLocalApi();
3125 if (!api) return;
3126 const clicked = await settlePromise(() =>
3127 api.contextMenu.show(
3128 [
3129 {
3130 id: "archive-all",
3131 label: "Archive all",
3132 disabled:
3133 settledThreads.length === 0 || archivingThreads || !canOperateThreads(settledThreads),
3134 },
3135 ],
3136 position,
3137 ),
3138 );
3139 if (clicked._tag === "Success" && clicked.value === "archive-all") {
3140 await archiveThreads(settledThreads, true);
3141 }
3142 };
3045 const [settledShelfExpanded, setSettledShelfExpanded] = useLocalStorage(3143 const [settledShelfExpanded, setSettledShelfExpanded] = useLocalStorage(
3046 SETTLED_SHELF_EXPANDED_KEY,3144 SETTLED_SHELF_EXPANDED_KEY,
3047 false,3145 false,
...@@ -3401,14 +3499,17 @@ export default function Sidebar() {...@@ -3401,14 +3499,17 @@ export default function Sidebar() {
3401 );3499 );
34023500
3403 const attemptSettle = useCallback(3501 const attemptSettle = useCallback(
3404 (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet<string> } = {}) => {3502 (
3503 threadRef: ScopedThreadRef,
3504 opts: { coSettlingKeys?: ReadonlySet<string>; confirmed?: true } = {},
3505 ) => {
3405 void (async () => {3506 void (async () => {
3406 const threadKey = scopedThreadKey(threadRef);3507 const threadKey = scopedThreadKey(threadRef);
3407 if (settlingThreadKeysRef.current.has(threadKey)) return;3508 if (settlingThreadKeysRef.current.has(threadKey)) return;
3408 settlingThreadKeysRef.current.add(threadKey);3509 settlingThreadKeysRef.current.add(threadKey);
3409 try {3510 try {
3410 const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys);3511 const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys);
3411 const result = await settleThread(threadRef);3512 const result = await settleThread(threadRef, opts.confirmed ? { confirmed: true } : {});
3412 if (result._tag === "Failure") {3513 if (result._tag === "Failure") {
3413 // Never navigate away from a thread that did not settle.3514 // Never navigate away from a thread that did not settle.
3414 if (!isAtomCommandInterrupted(result)) {3515 if (!isAtomCommandInterrupted(result)) {
...@@ -3451,15 +3552,35 @@ export default function Sidebar() {...@@ -3451,15 +3552,35 @@ export default function Sidebar() {
3451 // Pinned rows ARE included: the decider clears the pin as part of settling,3552 // Pinned rows ARE included: the decider clears the pin as part of settling,
3452 // so they park like the rest.3553 // so they park like the rest.
3453 const settleThreads = useCallback(3554 const settleThreads = useCallback(
3454 (threadKeys: readonly string[]) => {3555 async (threadKeys: readonly string[]) => {
3455 const coSettlingKeys = new Set(threadKeys);3556 const targets = threadKeys.flatMap((threadKey) => {
3456 for (const threadKey of threadKeys) {
3457 const thread = threadByKeyRef.current.get(threadKey);3557 const thread = threadByKeyRef.current.get(threadKey);
3458 if (!thread || thread.settledOverride === "settled") continue;3558 return thread && thread.settledOverride !== "settled"
3459 attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys });3559 ? [scopeThreadRef(thread.environmentId, thread.id)]
3560 : [];
3561 });
3562 const confirmation = await confirmThreadSettlement(targets);
3563 if (confirmation._tag === "Failure") {
3564 if (!isAtomCommandInterrupted(confirmation)) {
3565 const error = squashAtomCommandFailure(confirmation);
3566 toastManager.add(
3567 stackedThreadToast({
3568 type: "error",
3569 title: "Failed to settle chats",
3570 description: error instanceof Error ? error.message : "An error occurred.",
3571 }),
3572 );
3573 }
3574 return false;
3575 }
3576 if (!confirmation.value) return false;
3577 const coSettlingKeys = new Set(targets.map(scopedThreadKey));
3578 for (const target of targets) {
3579 attemptSettle(target, { coSettlingKeys, confirmed: true });
3460 }3580 }
3581 return true;
3461 },3582 },
3462 [attemptSettle],3583 [attemptSettle, confirmThreadSettlement],
3463 );3584 );
3464 const attemptUnsettle = useCallback(3585 const attemptUnsettle = useCallback(
3465 (threadRef: ScopedThreadRef) => {3586 (threadRef: ScopedThreadRef) => {
...@@ -4293,28 +4414,28 @@ export default function Sidebar() {...@@ -4293,28 +4414,28 @@ export default function Sidebar() {
4293 [performSnooze],4414 [performSnooze],
4294 );4415 );
42954416
4296 const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);
4297 const handleMultiSelectContextMenu = useCallback(4417 const handleMultiSelectContextMenu = useCallback(
4298 async (position: { x: number; y: number }) => {4418 async (position: { x: number; y: number }) => {
4299 const api = readLocalApi();4419 const api = readLocalApi();
4300 if (!api) return;4420 if (!api) return;
4301 // One exact actionable set: keys whose rows are actually rendered4421 const readSelectedThread = (threadKey: string) => {
4302 // right now. Selections can outlive their rows (settled-tail paging,4422 const threadRef = parseScopedThreadKey(threadKey);
4303 // thread deletion elsewhere) and the menu labels must count only what4423 const thread = threadRef ? readThreadShell(threadRef) : null;
4304 // the actions will touch.4424 return thread?.archivedAt === null ? thread : null;
4425 };
4305 const selectedThreadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys];4426 const selectedThreadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys];
4306 const threadKeys = selectedThreadKeys.filter((threadKey) =>4427 const selectedThreads = selectedThreadKeys.flatMap((threadKey) => {
4307 threadByKeyRef.current.has(threadKey),4428 const thread = readSelectedThread(threadKey);
4429 return thread ? [thread] : [];
4430 });
4431 const threadKeys = selectedThreads.map((thread) =>
4432 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
4308 );4433 );
4309 if (threadKeys.length === 0) return;4434 if (threadKeys.length === 0) return;
4310 const count = threadKeys.length;4435 const count = threadKeys.length;
4311 // Snooze (N) is offered when every selected thread can actually take4436 // Snooze (N) is offered when every selected thread can actually take
4312 // it — a mixed selection with blocked-on-you work would half-apply.4437 // it — a mixed selection with blocked-on-you work would half-apply.
4313 const selectionNow = new Date();4438 const selectionNow = new Date();
4314 const selectedThreads = threadKeys.flatMap((threadKey) => {
4315 const thread = threadByKeyRef.current.get(threadKey);
4316 return thread ? [thread] : [];
4317 });
4318 const settlingThreads = selectedThreads.filter(4439 const settlingThreads = selectedThreads.filter(
4319 (thread) => thread.settledOverride !== "settled",4440 (thread) => thread.settledOverride !== "settled",
4320 );4441 );
...@@ -4389,13 +4510,22 @@ export default function Sidebar() {...@@ -4389,13 +4510,22 @@ export default function Sidebar() {
4389 },4510 },
4390 ]4511 ]
4391 : []),4512 : []),
4392 { id: "mark-unread", label: `Mark unread (${count})` },4513 ...buildMultiSelectThreadContextMenuItems({
4393 {4514 count,
4394 id: "delete",4515 hasRunningThread: selectedThreads.some(
4395 label: `Delete (${count})`,4516 (thread) => !threadRuntimeCanArchive(thread.runtime),
4396 destructive: true,4517 ),
4397 disabled: !canOperateThreads(selectedThreads),4518 }).map((item) =>
4398 },4519 item.id === "mark-unread"
4520 ? item
4521 : {
4522 ...item,
4523 disabled:
4524 item.disabled ||
4525 !canOperateThreads(selectedThreads) ||
4526 (item.id === "archive" && archivingThreads),
4527 },
4528 ),
4399 ],4529 ],
4400 position,4530 position,
4401 ),4531 ),
...@@ -4408,7 +4538,7 @@ export default function Sidebar() {...@@ -4408,7 +4538,7 @@ export default function Sidebar() {
4408 ? regeneratableTitleThreads4538 ? regeneratableTitleThreads
4409 : clicked.value === "settle"4539 : clicked.value === "settle"
4410 ? settlingThreads.flatMap((thread) => {4540 ? settlingThreads.flatMap((thread) => {
4411 const current = threadByKeyRef.current.get(4541 const current = readSelectedThread(
4412 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),4542 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
4413 );4543 );
4414 return current && current.settledOverride !== "settled" ? [current] : [];4544 return current && current.settledOverride !== "settled" ? [current] : [];
...@@ -4497,22 +4627,28 @@ export default function Sidebar() {...@@ -4497,22 +4627,28 @@ export default function Sidebar() {
4497 return;4627 return;
4498 }4628 }
4499 if (clicked.value === "settle") {4629 if (clicked.value === "settle") {
4500 settleThreads(4630 if (
4501 actionTargets.map((thread) =>4631 await settleThreads(
4502 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),4632 actionTargets.map((thread) =>
4503 ),4633 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
4504 );4634 ),
4505 clearSelection();4635 )
4636 )
4637 clearSelection();
4506 return;4638 return;
4507 }4639 }
4508 if (clicked.value === "mark-unread") {4640 if (clicked.value === "mark-unread") {
4509 for (const threadKey of threadKeys) {4641 for (const threadKey of threadKeys) {
4510 const thread = threadByKeyRef.current.get(threadKey);4642 const thread = readSelectedThread(threadKey);
4511 if (thread) markThreadUnread(scopeThreadRef(thread.environmentId, thread.id));4643 if (thread) markThreadUnread(scopeThreadRef(thread.environmentId, thread.id));
4512 }4644 }
4513 clearSelection();4645 clearSelection();
4514 return;4646 return;
4515 }4647 }
4648 if (clicked.value === "archive") {
4649 await archiveThreads(selectedThreads);
4650 return;
4651 }
4516 if (clicked.value !== "delete") return;4652 if (clicked.value !== "delete") return;
4517 if (confirmThreadDelete) {4653 if (confirmThreadDelete) {
4518 const confirmed = await settlePromise(() =>4654 const confirmed = await settlePromise(() =>
...@@ -4529,7 +4665,7 @@ export default function Sidebar() {...@@ -4529,7 +4665,7 @@ export default function Sidebar() {
4529 if (4665 if (
4530 !checkThreadOperations(4666 !checkThreadOperations(
4531 threadKeys.flatMap((threadKey) => {4667 threadKeys.flatMap((threadKey) => {
4532 const thread = threadByKeyRef.current.get(threadKey);4668 const thread = readSelectedThread(threadKey);
4533 return thread ? [thread] : [];4669 return thread ? [thread] : [];
4534 }),4670 }),
4535 )4671 )
...@@ -4538,7 +4674,7 @@ export default function Sidebar() {...@@ -4538,7 +4674,7 @@ export default function Sidebar() {
4538 const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({4674 const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({
4539 entries: threadKeys.map((threadKey) => ({ threadKey })),4675 entries: threadKeys.map((threadKey) => ({ threadKey })),
4540 delete: async ({ threadKey }, deletedThreadKeys) => {4676 delete: async ({ threadKey }, deletedThreadKeys) => {
4541 const thread = threadByKeyRef.current.get(threadKey);4677 const thread = readSelectedThread(threadKey);
4542 if (!thread) return null;4678 if (!thread) return null;
4543 return deleteThread(scopeThreadRef(thread.environmentId, thread.id), {4679 return deleteThread(scopeThreadRef(thread.environmentId, thread.id), {
4544 deletedThreadKeys,4680 deletedThreadKeys,
...@@ -4563,6 +4699,8 @@ export default function Sidebar() {...@@ -4563,6 +4699,8 @@ export default function Sidebar() {
4563 );4699 );
4564 },4700 },
4565 [4701 [
4702 archiveThreads,
4703 archivingThreads,
4566 attemptUnpin,4704 attemptUnpin,
4567 clearSelection,4705 clearSelection,
4568 confirmThreadDelete,4706 confirmThreadDelete,
...@@ -5577,6 +5715,9 @@ export default function Sidebar() {...@@ -5577,6 +5715,9 @@ export default function Sidebar() {
5577 expanded: settledShelfExpanded,5715 expanded: settledShelfExpanded,
5578 onToggle: toggleSettledShelf,5716 onToggle: toggleSettledShelf,
5579 }}5717 }}
5718 onContextMenu={(position) =>
5719 void handleSettledContextMenu(position)
5720 }
5580 />,5721 />,
5581 );5722 );
5582 break;5723 break;
...@@ -5607,10 +5748,10 @@ export default function Sidebar() {...@@ -5607,10 +5748,10 @@ export default function Sidebar() {
5607 <button5748 <button
5608 type="button"5749 type="button"
5609 onClick={showMoreSettled}5750 onClick={showMoreSettled}
5610 className="flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm text-sidebar-muted-foreground/55 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"5751 className="flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm text-sidebar-muted-foreground/55 hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
5611 >5752 >
5612 <PlusIcon aria-hidden className="size-4 shrink-0" />5753 <PlusIcon aria-hidden className="size-4 shrink-0" />
5613 Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more5754 Show {hiddenSettledCount} more
5614 </button>5755 </button>
5615 </li>5756 </li>
5616 ) : null}5757 ) : null}
apps/web/src/components/chat/MessagesTimeline.test.tsx+86
...@@ -8,7 +8,10 @@ import {...@@ -8,7 +8,10 @@ import {
8 ProjectId,8 ProjectId,
9 RunId,9 RunId,
10 ThreadId,10 ThreadId,
11 TurnItemId,
12 type OrchestrationV2TurnItem,
11} from "@t3tools/contracts";13} from "@t3tools/contracts";
14import * as DateTime from "effect/DateTime";
12import {15import {
13 act,16 act,
14 createRef,17 createRef,
...@@ -553,6 +556,89 @@ describe("MessagesTimeline", () => {...@@ -553,6 +556,89 @@ describe("MessagesTimeline", () => {
553 }556 }
554 });557 });
555558
559 it("shows a system notification's actionable link without opening its inspector", async () => {
560 vi.unstubAllGlobals();
561 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
562 Object.defineProperty(window, "matchMedia", { value: matchMedia, configurable: true });
563 vi.stubGlobal("requestAnimationFrame", () => 0);
564 vi.stubGlobal("cancelAnimationFrame", () => {});
565 const item = {
566 id: TurnItemId.make("ci-failure"),
567 threadId: ThreadId.make("thread-1"),
568 runId: null,
569 nodeId: null,
570 providerThreadId: null,
571 providerTurnId: null,
572 nativeItemRef: null,
573 parentItemId: null,
574 ordinal: 0,
575 status: "completed",
576 title: null,
577 startedAt: null,
578 completedAt: null,
579 updatedAt: DateTime.makeUnsafe(MESSAGE_CREATED_AT),
580 type: "notification",
581 source: { kind: "system" },
582 outcome: "failed",
583 summary: "CI failed for PR #42",
584 detail: "[Open PR #42 in Mergebot](https://example.com/queue/pr/42)",
585 } satisfies OrchestrationV2TurnItem;
586 const container = document.createElement("div");
587 document.body.append(container);
588 const root = createRoot(container);
589 try {
590 await act(() => {
591 root.render(
592 <MessagesTimeline
593 {...buildProps()}
594 timelineEntries={[
595 {
596 id: item.id,
597 kind: "work",
598 createdAt: MESSAGE_CREATED_AT,
599 entry: {
600 id: item.id,
601 createdAt: MESSAGE_CREATED_AT,
602 runId: null,
603 label: item.summary,
604 detail: item.detail,
605 tone: "error",
606 itemType: "notification",
607 toolLifecycleStatus: "completed",
608 structuredPayload: item,
609 projectedItem: {
610 position: 0,
611 visibility: "local",
612 sourceThreadId: item.threadId,
613 sourceItemId: item.id,
614 item,
615 },
616 },
617 },
618 ]}
619 />,
620 );
621 });
622 const notice = container.querySelector('[data-v2-item-type="notification"]')!;
623 expect(notice.textContent).toContain("CI failed for PR #42");
624 expect(notice.querySelector("[aria-expanded], button")).toBeNull();
625 const link = notice.querySelector<HTMLAnchorElement>("a")!;
626 expect(link.textContent).toBe("Open PR #42 in Mergebot");
627 expect(link.href).toBe("https://example.com/queue/pr/42");
628 expect(link.closest('[hidden], [aria-hidden="true"]')).toBeNull();
629 await act(() => link.focus());
630 expect(document.activeElement).toBe(link);
631 const follow = vi.fn((event: Event) => event.preventDefault());
632 link.addEventListener("click", follow);
633 await act(() => link.click());
634 expect(follow).toHaveBeenCalledOnce();
635 } finally {
636 await act(() => root.unmount());
637 container.remove();
638 vi.unstubAllGlobals();
639 }
640 });
641
556 it("shows dynamic tool input without cached output when the row is expanded", async () => {642 it("shows dynamic tool input without cached output when the row is expanded", async () => {
557 activityTestState.expanded = true;643 activityTestState.expanded = true;
558 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);644 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
apps/web/src/components/chat/MessagesTimeline.tsx+40
...@@ -5529,6 +5529,46 @@ type WorkEntryRowProps = {...@@ -5529,6 +5529,46 @@ type WorkEntryRowProps = {
5529const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: WorkEntryRowProps) {5529const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: WorkEntryRowProps) {
5530 const ctx = use(TimelineRowCtx);5530 const ctx = use(TimelineRowCtx);
5531 const item = props.workEntry.projectedItem?.item;5531 const item = props.workEntry.projectedItem?.item;
5532 if (item?.type === "notification" && item.source.kind === "system") {
5533 const failed = item.outcome === "failed";
5534 return (
5535 <WorkLogRow
5536 role="status"
5537 data-v2-item-type="notification"
5538 data-v2-item-visibility={props.workEntry.projectedItem?.visibility}
5539 wrapLabel
5540 icon={
5541 <WorkEntryIcon
5542 name={failed ? "circle-alert" : "t3-code"}
5543 className={cn("size-4", failed ? "text-destructive" : "text-secondary-label")}
5544 />
5545 }
5546 label={
5547 <span className={cn("text-sm font-medium", failed && "text-destructive")}>
5548 {item.summary}
5549 </span>
5550 }
5551 trailing={
5552 <TimelineRowTimestamp
5553 createdAt={props.workEntry.createdAt}
5554 timestampFormat={ctx.timestampFormat}
5555 alwaysVisible
5556 />
5557 }
5558 >
5559 {item.detail ? (
5560 <div className="ms-7 py-1 text-sm" data-thread-find-text="true">
5561 <ChatMarkdown
5562 text={item.detail}
5563 cwd={ctx.markdownCwd}
5564 threadRef={ctx.threadRef ?? undefined}
5565 headingLevelOffset={MESSAGE_HEADING_LEVEL}
5566 />
5567 </div>
5568 ) : null}
5569 </WorkLogRow>
5570 );
5571 }
5532 const childThreadId =5572 const childThreadId =
5533 item?.type === "notification" ? notificationChildThreadId(item.source) : undefined;5573 item?.type === "notification" ? notificationChildThreadId(item.source) : undefined;
5534 if (item?.type !== "notification" || childThreadId === undefined) {5574 if (item?.type !== "notification" || childThreadId === undefined) {
apps/web/src/components/sidebar/NewWorkWorkspaceButton.tsx created+40
...@@ -0,0 +1,40 @@
1import { createLucideIcon } from "lucide-react";
2
3import { useNewWorkWorkspace } from "../../hooks/useNewWorkWorkspace";
4import { Button } from "../ui/button";
5import { useSidebar } from "../ui/sidebar";
6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
7
8const BriefcasePlusIcon = createLucideIcon("briefcase-plus", [
9 ["path", { d: "M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2", key: "handle" }],
10 ["path", { d: "M12 20H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v4", key: "case" }],
11 ["path", { d: "M16 18h6M19 15v6", key: "plus" }],
12]);
13
14export function NewWorkWorkspaceButton() {
15 const { setOpenMobile } = useSidebar();
16 const { project, pending, label, disabled, run } = useNewWorkWorkspace(() =>
17 setOpenMobile(false),
18 );
19 if (!project) return null;
20
21 return (
22 <Tooltip>
23 <TooltipTrigger
24 render={
25 <Button
26 variant="ghost"
27 size="icon-sm"
28 aria-label={label}
29 aria-busy={pending}
30 disabled={disabled}
31 onClick={() => void run()}
32 >
33 <BriefcasePlusIcon className="size-4" aria-hidden="true" />
34 </Button>
35 }
36 />
37 <TooltipPopup side="bottom">{label}</TooltipPopup>
38 </Tooltip>
39 );
40}
apps/web/src/components/sidebar/SidebarChrome.tsx+5-1
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19} from "../ui/sidebar";19} from "../ui/sidebar";
20import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation";20import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation";
21import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice";21import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice";
22import { NewWorkWorkspaceButton } from "./NewWorkWorkspaceButton";
22import { AppMenu } from "./AppMenu";23import { AppMenu } from "./AppMenu";
23import { SidebarMergebotButton } from "./SidebarMergebotButton";24import { SidebarMergebotButton } from "./SidebarMergebotButton";
24import { SidebarUsageButton } from "./SidebarUsageButton";25import { SidebarUsageButton } from "./SidebarUsageButton";
...@@ -49,9 +50,12 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({...@@ -49,9 +50,12 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
49 <AppMenu />50 <AppMenu />
50 </div>51 </div>
51 <SidebarTrigger aria-label="Toggle main sidebar" />52 <SidebarTrigger aria-label="Toggle main sidebar" />
53 <div className="ml-2">
54 <NewWorkWorkspaceButton />
55 </div>
52 </div>56 </div>
53 {pillLabel ? (57 {pillLabel ? (
54 <div className="relative z-10 ml-[var(--workspace-titlebar-content-left)] flex h-7 min-w-0 items-center">58 <div className="relative z-10 ml-[calc(var(--workspace-titlebar-content-left)+2.25rem)] max-md:ml-0 flex h-7 min-w-0 items-center">
55 <Badge data-environment-identification="pill" size="sm" variant="secondary">59 <Badge data-environment-identification="pill" size="sm" variant="secondary">
56 {pillLabel}60 {pillLabel}
57 </Badge>61 </Badge>
apps/web/src/hooks/useNewWorkWorkspace.test.tsx created+202
...@@ -0,0 +1,202 @@
1import { RegistryContext } from "@effect/atom-react";
2import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity";
3import * as Cause from "effect/Cause";
4import { act } from "react";
5import { create, type ReactTestRenderer } from "react-test-renderer";
6import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
7
8const state = vi.hoisted(() => ({
9 create: vi.fn(),
10 navigate: vi.fn(),
11 toast: vi.fn(),
12 sidebarOpen: true,
13 onNavigated: vi.fn(() => {
14 state.sidebarOpen = false;
15 }),
16 router: { state: { location: { href: "/existing-chat" } } },
17 target: null as object | null,
18 draftEnvironmentId: null as string | null,
19 allowed: true,
20 connected: true,
21}));
22vi.mock("@tanstack/react-router", () => ({
23 useNavigate: () => state.navigate,
24 useRouter: () => state.router,
25 useParams: () => state.target,
26}));
27vi.mock("../state/environments", () => ({
28 usePrimaryEnvironmentId: () => "local",
29 useEnvironment: () => ({
30 connection: { phase: state.connected ? "connected" : "offline" },
31 serverConfig: { managedWorkspaces: true },
32 }),
33}));
34vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => state.draftEnvironmentId }));
35vi.mock("../state/entities", () => ({ useProject: () => ({ id: "source" }) }));
36vi.mock("../state/query", () => ({
37 useEnvironmentQuery: () => ({ isSuccess: true, data: { projectId: "source" } }),
38}));
39vi.mock("../state/session", () => ({ useEnvironmentScope: () => true }));
40vi.mock("../state/managedWorkspaces", () => ({
41 managedWorkspaceStatus: () => null,
42 managedWorkspacePending: Atom.family(() => Atom.make(false)),
43 createManagedWorkspace: { permissionAtom: () => Atom.make(state.allowed) },
44}));
45vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => state.create }));
46vi.mock("../components/ui/toast", () => ({ toastManager: { add: state.toast } }));
47
48import { useNewWorkWorkspace } from "./useNewWorkWorkspace";
49
50let renderer: ReactTestRenderer;
51let registry: ReturnType<typeof AtomRegistry.make>;
52let actions: ReturnType<typeof useNewWorkWorkspace>[];
53function Probe({ onNavigated }: { onNavigated?: () => void }) {
54 actions.push(useNewWorkWorkspace(onNavigated));
55 return null;
56}
57const created = {
58 environmentId: "local",
59 projectId: "new-project",
60 threadId: "new-thread",
61 workspacePath: "/work/new",
62};
63async function mount() {
64 await act(() => {
65 renderer = create(
66 <RegistryContext.Provider value={registry}>
67 <Probe onNavigated={state.onNavigated} />
68 <Probe />
69 </RegistryContext.Provider>,
70 );
71 });
72}
73beforeEach(() => {
74 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
75 registry = AtomRegistry.make();
76 actions = [];
77 state.target = null;
78 state.draftEnvironmentId = null;
79 state.allowed = true;
80 state.connected = true;
81 state.router.state.location.href = "/existing-chat";
82 state.create.mockReset().mockResolvedValue(AsyncResult.success(created));
83 state.navigate.mockReset();
84 state.toast.mockReset();
85 state.sidebarOpen = true;
86 state.onNavigated.mockClear();
87});
88afterEach(async () => {
89 await act(() => renderer?.unmount());
90 registry.dispose();
91 vi.unstubAllGlobals();
92});
93
94it("shares the in-flight guard between header and command palette, then opens the returned thread", async () => {
95 let finish!: (value: unknown) => void;
96 state.create.mockReturnValue(
97 new Promise((resolve) => {
98 finish = resolve;
99 }),
100 );
101 await mount();
102 let first!: Promise<void>;
103 await act(async () => {
104 first = actions[0]!.run();
105 await actions[1]!.run();
106 });
107 expect(state.create).toHaveBeenCalledTimes(1);
108 await act(async () => {
109 finish(AsyncResult.success(created));
110 await first;
111 });
112 expect(state.navigate).toHaveBeenCalledExactlyOnceWith({
113 to: "/$environmentId/$threadId",
114 params: { environmentId: "local", threadId: "new-thread" },
115 });
116});
117
118it("releases the guard after failure so retry creates a workspace", async () => {
119 state.create.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("ws failed"))));
120 await mount();
121 await act(() => actions[0]!.run());
122 expect(state.navigate).not.toHaveBeenCalled();
123 expect(state.sidebarOpen).toBe(true);
124 expect(state.toast).toHaveBeenCalledWith(
125 expect.objectContaining({ type: "error", description: "ws failed" }),
126 );
127 await act(() => actions.at(-1)!.run());
128 expect(state.create).toHaveBeenCalledTimes(2);
129 expect(state.navigate).toHaveBeenCalledTimes(1);
130});
131
132it("keeps a newly selected chat open while creation finishes and offers an explicit Open action", async () => {
133 let finish!: (value: unknown) => void;
134 state.create.mockReturnValue(
135 new Promise((resolve) => {
136 finish = resolve;
137 }),
138 );
139 await mount();
140 let pending!: Promise<void>;
141 await act(() => {
142 pending = actions[0]!.run();
143 });
144 state.router.state.location.href = "/another-chat";
145 await act(async () => {
146 finish(AsyncResult.success(created));
147 await pending;
148 });
149 expect(state.navigate).not.toHaveBeenCalled();
150 expect(state.sidebarOpen).toBe(true);
151 const toast = state.toast.mock.calls[0]![0];
152 expect(toast).toMatchObject({ type: "success", description: "/work/new" });
153 await toast.actionProps.onClick();
154 expect(state.navigate).toHaveBeenCalledTimes(1);
155});
156
157it.each(["offline", "denied"])("does not create when %s", async (unavailable) => {
158 state.connected = unavailable !== "offline";
159 state.allowed = unavailable !== "denied";
160 await mount();
161 await act(() => actions[0]!.run());
162 expect(state.create).not.toHaveBeenCalled();
163});
164
165it("creates on the draft's environment instead of the primary environment", async () => {
166 state.target = { kind: "draft", draftId: "remote-draft" };
167 state.draftEnvironmentId = "remote";
168 await mount();
169 await act(() => actions[0]!.run());
170 expect(state.create).toHaveBeenCalledExactlyOnceWith({ environmentId: "remote", input: {} });
171});
172
173it("reveals the created workspace only after its navigation succeeds", async () => {
174 let finishNavigation!: () => void;
175 state.navigate.mockReturnValue(
176 new Promise<void>((resolve) => {
177 finishNavigation = resolve;
178 }),
179 );
180 await mount();
181 let pending!: Promise<void>;
182 await act(async () => {
183 pending = actions[0]!.run();
184 });
185 expect(state.navigate).toHaveBeenCalledTimes(1);
186 expect(state.sidebarOpen).toBe(true);
187 await act(async () => {
188 finishNavigation();
189 await pending;
190 });
191 expect(state.sidebarOpen).toBe(false);
192});
193
194it("keeps the mobile sidebar open if navigation to the created workspace fails", async () => {
195 state.navigate.mockRejectedValue(new Error("navigation failed"));
196 await mount();
197 await act(() => actions[0]!.run());
198 expect(state.sidebarOpen).toBe(true);
199 expect(state.toast).toHaveBeenCalledWith(
200 expect.objectContaining({ type: "error", description: "navigation failed" }),
201 );
202});
apps/web/src/hooks/useNewWorkWorkspace.ts created+111
...@@ -0,0 +1,111 @@
1import { RegistryContext, useAtomValue } from "@effect/atom-react";
2import { scopeProjectRef } from "@t3tools/client-runtime/environment";
3import {
4 isAtomCommandInterrupted,
5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";
7import { AuthFilesystemReadScope } from "@t3tools/contracts";
8import { useNavigate, useParams, useRouter } from "@tanstack/react-router";
9import { useCallback, useContext } from "react";
10
11import { toastManager } from "../components/ui/toast";
12import { resolveThreadRouteTarget } from "../threadRoutes";
13import { useComposerDraftStore } from "../composerDraftStore";
14import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
15import { useProject } from "../state/entities";
16import {
17 createManagedWorkspace,
18 managedWorkspacePending,
19 managedWorkspaceStatus,
20} from "../state/managedWorkspaces";
21import { useEnvironmentQuery } from "../state/query";
22import { useEnvironmentScope } from "../state/session";
23import { useAtomCommand } from "../state/use-atom-command";
24
25export function useNewWorkWorkspace(onNavigated?: () => void) {
26 const target = useParams({ strict: false, select: resolveThreadRouteTarget });
27 const draftEnvironmentId = useComposerDraftStore((store) =>
28 target?.kind === "draft" ? store.getDraftSession(target.draftId)?.environmentId : null,
29 );
30 const primaryEnvironmentId = usePrimaryEnvironmentId();
31 const environmentId =
32 target?.kind === "server"
33 ? target.threadRef.environmentId
34 : (draftEnvironmentId ?? primaryEnvironmentId);
35 const environment = useEnvironment(environmentId);
36 const connected = environment?.connection.phase === "connected";
37 const supported = environment?.serverConfig?.managedWorkspaces === true;
38 const canRead = useEnvironmentScope(environmentId, AuthFilesystemReadScope);
39 const allowed = useAtomValue(createManagedWorkspace.permissionAtom(environmentId));
40 const status = useEnvironmentQuery(
41 environmentId && connected && supported && canRead
42 ? managedWorkspaceStatus({ environmentId, input: {} })
43 : null,
44 );
45 const project = useProject(
46 environmentId && status.isSuccess && status.data?.projectId
47 ? scopeProjectRef(environmentId, status.data.projectId)
48 : null,
49 );
50 const pendingAtom = managedWorkspacePending(environmentId);
51 const pending = useAtomValue(pendingAtom);
52 const registry = useContext(RegistryContext);
53 const create = useAtomCommand(createManagedWorkspace, { reportFailure: false });
54 const navigate = useNavigate();
55 const router = useRouter();
56 const run = useCallback(async () => {
57 if (!environmentId || !connected || !allowed || !project || registry.get(pendingAtom)) return;
58 registry.set(pendingAtom, true);
59 const origin = router.state.location.href;
60 try {
61 const result = await create({ environmentId, input: {} });
62 if (result._tag === "Failure") {
63 if (!isAtomCommandInterrupted(result)) throw squashAtomCommandFailure(result);
64 return;
65 }
66 const open = async () => {
67 await navigate({
68 to: "/$environmentId/$threadId",
69 params: { environmentId: result.value.environmentId, threadId: result.value.threadId },
70 });
71 onNavigated?.();
72 };
73 if (router.state.location.href === origin) await open();
74 else
75 toastManager.add({
76 type: "success",
77 title: "Workspace for my work ready",
78 description: result.value.workspacePath,
79 actionProps: { children: "Open", onClick: () => void open() },
80 });
81 } catch (error) {
82 toastManager.add({
83 type: "error",
84 title: "Could not create workspace",
85 description:
86 error instanceof Error ? error.message : "Try again when the environment is connected.",
87 });
88 } finally {
89 registry.set(pendingAtom, false);
90 }
91 }, [
92 environmentId,
93 connected,
94 allowed,
95 project,
96 registry,
97 pendingAtom,
98 router,
99 create,
100 navigate,
101 onNavigated,
102 ]);
103
104 return {
105 project,
106 pending,
107 label: pending ? "Creating workspace for my work…" : "New workspace for my work",
108 disabled: !connected || !allowed || pending,
109 run,
110 };
111}
apps/web/src/hooks/useThreadActions.permissions.test.ts+110-4
...@@ -8,7 +8,7 @@ import {...@@ -8,7 +8,7 @@ import {
8 type ScopedThreadRef,8 type ScopedThreadRef,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import * as Cause from "effect/Cause";10import * as Cause from "effect/Cause";
11import { AsyncResult } from "effect/reactivity";11import { AsyncResult, Atom } from "effect/reactivity";
12import { beforeEach, describe, expect, it, vi } from "vite-plus/test";12import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
1313
14function deferred<T>() {14function deferred<T>() {
...@@ -19,8 +19,42 @@ function deferred<T>() {...@@ -19,8 +19,42 @@ function deferred<T>() {
19 return { promise, resolve };19 return { promise, resolve };
20}20}
2121
22const resourceQueries = vi.hoisted(() => ({
23 calls: 0,
24 terminals: null as { threadId: string; hasRunningSubprocess: boolean }[] | null,
25 tabs: null as unknown[] | null,
26}));
27vi.mock("@t3tools/client-runtime/state/runtime", async (original) => ({
28 ...(await original<typeof import("@t3tools/client-runtime/state/runtime")>()),
29 executeAtomQuery: async (_registry: unknown, atom: string) => {
30 resourceQueries.calls++;
31 const value =
32 atom === "terminal-metadata" ? resourceQueries.terminals : { sessions: resourceQueries.tabs };
33 return (atom === "terminal-metadata" ? resourceQueries.terminals : resourceQueries.tabs) ===
34 null
35 ? { _tag: "Failure", cause: {} }
36 : { _tag: "Success", value };
37 },
38}));
39vi.mock("../connection/hub", () => ({
40 hubConnection: {
41 organizationPermissionAtom: (id: EnvironmentId) => Atom.make(state.organization.has(id)),
42 },
43}));
44vi.mock("../connection/catalog", () => ({
45 environmentCatalog: {
46 stateAtom: (_id: EnvironmentId) => Atom.make(AsyncResult.success({ phase: "offline" })),
47 catalogValueAtom: Atom.make({ isReady: true, entries: new Map() }),
48 },
49}));
50vi.mock("../state/terminal", () => ({
51 terminalEnvironment: { metadataSnapshot: () => "terminal-metadata" },
52}));
53vi.mock("../state/preview", () => ({ previewEnvironment: { list: () => "preview-list" } }));
54
22const state = vi.hoisted(() => ({55const state = vi.hoisted(() => ({
23 scopes: new Map<string, Set<string>>(),56 scopes: new Map<string, Set<string>>(),
57 organization: new Set<string>(),
24 threads: [] as {58 threads: [] as {
25 environmentId: EnvironmentId;59 environmentId: EnvironmentId;
26 id: ThreadId;60 id: ThreadId;
...@@ -47,6 +81,8 @@ vi.mock("@tanstack/react-router", () => ({...@@ -47,6 +81,8 @@ vi.mock("@tanstack/react-router", () => ({
47 useRouter: () => ({ state: { matches: [] }, navigate: async () => {} }),81 useRouter: () => ({ state: { matches: [] }, navigate: async () => {} }),
48}));82}));
49vi.mock("../state/session", () => ({83vi.mock("../state/session", () => ({
84 readThreadOrganizationPermission: (id: string) =>
85 state.organization.has(id) || state.scopes.get(id)?.has("orchestration:operate") === true,
50 environmentSession: { sessionStateAtom: "session" },86 environmentSession: { sessionStateAtom: "session" },
51 readEnvironmentScope: (environmentId: string, scope: string) =>87 readEnvironmentScope: (environmentId: string, scope: string) =>
52 state.scopes.get(environmentId)?.has(scope) === true,88 state.scopes.get(environmentId)?.has(scope) === true,
...@@ -58,7 +94,23 @@ vi.mock("../state/use-atom-command", () => ({...@@ -58,7 +94,23 @@ vi.mock("../state/use-atom-command", () => ({
58 state.requests.push({ action, ...request });94 state.requests.push({ action, ...request });
59 const scope =95 const scope =
60 action === "removeWorktree" ? AuthSourceControlWriteScope : AuthOrchestrationOperateScope;96 action === "removeWorktree" ? AuthSourceControlWriteScope : AuthOrchestrationOperateScope;
61 if (!state.scopes.get(request.environmentId)?.has(scope)) {97 if (
98 !state.scopes.get(request.environmentId)?.has(scope) &&
99 !(
100 state.organization.has(request.environmentId) &&
101 [
102 "settle",
103 "unsettle",
104 "snooze",
105 "unsnooze",
106 "pin",
107 "unpin",
108 "reorderPin",
109 "reorderActive",
110 "setAutoSettle",
111 ].includes(action)
112 )
113 ) {
62 return AsyncResult.failure(Cause.fail(new Error("Server denied the request")));114 return AsyncResult.failure(Cause.fail(new Error("Server denied the request")));
63 }115 }
64 state.afterRequest?.(action);116 state.afterRequest?.(action);
...@@ -111,7 +163,8 @@ vi.mock("../state/entities", () => ({...@@ -111,7 +163,8 @@ vi.mock("../state/entities", () => ({
111 .map((thread) => ({ environmentId, threadId: thread.id })),163 .map((thread) => ({ environmentId, threadId: thread.id })),
112 readProject: () => ({ workspaceRoot: "/project" }),164 readProject: () => ({ workspaceRoot: "/project" }),
113}));165}));
114vi.mock("../components/Sidebar.logic", () => ({166vi.mock("../components/Sidebar.logic", async (original) => ({
167 ...(await original<typeof import("../components/Sidebar.logic")>()),
115 getFallbackThreadIdAfterDelete: () => null,168 getFallbackThreadIdAfterDelete: () => null,
116 pinOrderKeyBetween: () => "a",169 pinOrderKeyBetween: () => "a",
117}));170}));
...@@ -166,6 +219,7 @@ vi.mock("./useSettings", () => ({...@@ -166,6 +219,7 @@ vi.mock("./useSettings", () => ({
166}));219}));
167220
168import { useThreadActions } from "./useThreadActions";221import { useThreadActions } from "./useThreadActions";
222import { archiveSelectedThreadEntries } from "../components/Sidebar.logic";
169223
170const primary = EnvironmentId.make("primary");224const primary = EnvironmentId.make("primary");
171const secondary = EnvironmentId.make("secondary");225const secondary = EnvironmentId.make("secondary");
...@@ -209,6 +263,9 @@ const operations = [...@@ -209,6 +263,9 @@ const operations = [
209] as const;263] as const;
210264
211beforeEach(() => {265beforeEach(() => {
266 resourceQueries.calls = 0;
267 resourceQueries.terminals = null;
268 resourceQueries.tabs = null;
212 state.scopes = new Map([269 state.scopes = new Map([
213 [primary, new Set([AuthOrchestrationOperateScope])],270 [primary, new Set([AuthOrchestrationOperateScope])],
214 [secondary, new Set<string>()],271 [secondary, new Set<string>()],
...@@ -230,9 +287,44 @@ beforeEach(() => {...@@ -230,9 +287,44 @@ beforeEach(() => {
230 state.toasts = [];287 state.toasts = [];
231 state.afterRequest = undefined;288 state.afterRequest = undefined;
232 state.sessionLookupFails = false;289 state.sessionLookupFails = false;
290 state.organization.clear();
233});291});
234292
235describe("thread action permissions", () => {293describe("thread action permissions", () => {
294 it("stops a bulk archive when its environment loses permission between entries", async () => {
295 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
296 const second = { ...target, threadId: ThreadId.make("second") };
297 state.threads.push({ ...state.threads[0]!, id: second.threadId });
298 state.afterRequest = (action) => {
299 if (action === "archive") state.scopes.get(secondary)!.clear();
300 };
301 const actions = useThreadActions();
302 const entries = [target, second].map((ref) => ({ threadKey: ref.threadId, ref }));
303 const result = await archiveSelectedThreadEntries({
304 entries,
305 archive: (entry, onArchived) => actions.archiveThread(entry.ref, { onArchived }),
306 });
307 expect(state.requests.map((request) => request.input.threadId)).toEqual([target.threadId]);
308 expect(result.archivedThreadKeys).toEqual([target.threadId]);
309 expect(result.mutationFailure?._tag).toBe("Failure");
310 });
311
312 it("rechecks permission after a settle confirmation without dispatching", async () => {
313 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
314 const confirmed = deferred<boolean>();
315 state.confirm.mockReturnValue(confirmed.promise);
316 const pending = useThreadActions().settleThread(target);
317 expect(state.requests).toEqual([]);
318 state.scopes.get(secondary)!.clear();
319
320 confirmed.resolve(true);
321 const result = await pending;
322
323 expect(result._tag).toBe("Failure");
324 expect(state.requests).toEqual([]);
325 expect(state.localEffects).toEqual([]);
326 });
327
236 it.each(operations)("$name requires the target environment's grant", async ({ run }) => {328 it.each(operations)("$name requires the target environment's grant", async ({ run }) => {
237 const result = await run(useThreadActions(), target);329 const result = await run(useThreadActions(), target);
238 expect(result._tag).toBe("Failure");330 expect(result._tag).toBe("Failure");
...@@ -277,7 +369,7 @@ describe("thread action permissions", () => {...@@ -277,7 +369,7 @@ describe("thread action permissions", () => {
277 const confirmation = deferred<boolean>();369 const confirmation = deferred<boolean>();
278 state.confirm.mockReturnValue(confirmation.promise);370 state.confirm.mockReturnValue(confirmation.promise);
279 const result = useThreadActions()[action](target);371 const result = useThreadActions()[action](target);
280 expect(state.confirm).toHaveBeenCalledOnce();372 await vi.waitFor(() => expect(state.confirm).toHaveBeenCalledOnce());
281 state.scopes.get(secondary)!.clear();373 state.scopes.get(secondary)!.clear();
282 confirmation.resolve(true);374 confirmation.resolve(true);
283 await result;375 await result;
...@@ -353,3 +445,17 @@ describe("thread action permissions", () => {...@@ -353,3 +445,17 @@ describe("thread action permissions", () => {
353 }445 }
354 });446 });
355});447});
448
449it("uses the central grant for organization without authorizing source execution", async () => {
450 resourceQueries.terminals = [];
451 resourceQueries.tabs = [];
452 state.organization.add(secondary);
453 const actions = useThreadActions();
454 expect((await actions.settleThread(target))._tag).toBe("Success");
455 expect(resourceQueries.calls).toBe(0);
456 expect((await actions.deleteThread(target))._tag).toBe("Failure");
457 expect(state.requests.map((request) => request.action)).toEqual(["settle"]);
458 state.organization.clear();
459 expect((await actions.unsettleThread(target))._tag).toBe("Failure");
460 expect(state.requests).toHaveLength(1);
461});
apps/web/src/hooks/useThreadActions.ts+163-26
...@@ -1,11 +1,21 @@...@@ -1,11 +1,21 @@
1import { environmentCatalog } from "../connection/catalog";
2import { hubConnection } from "../connection/hub";
1import {3import {
2 parseScopedThreadKey,4 parseScopedThreadKey,
3 scopeProjectRef,5 scopeProjectRef,
4 scopeThreadRef,6 scopeThreadRef,
5 scopedThreadKey,7 scopedThreadKey,
6} from "@t3tools/client-runtime/environment";8} from "@t3tools/client-runtime/environment";
7import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";9import {
8import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";10 executeAtomQuery,
11 settlePromise,
12 squashAtomCommandFailure,
13} from "@t3tools/client-runtime/state/runtime";
14import {
15 canSnooze,
16 threadWokeAt,
17 resolveThreadSettlementWarning,
18} from "@t3tools/client-runtime/state/thread-settled";
9import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models";19import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models";
10import {20import {
11 AuthOrchestrationOperateScope,21 AuthOrchestrationOperateScope,
...@@ -18,6 +28,7 @@ import {...@@ -18,6 +28,7 @@ import {
18} from "@t3tools/contracts";28} from "@t3tools/contracts";
19import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";29import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
20import * as Cause from "effect/Cause";30import * as Cause from "effect/Cause";
31import * as Option from "effect/Option";
21import * as Schema from "effect/Schema";32import * as Schema from "effect/Schema";
22import { AsyncResult } from "effect/reactivity";33import { AsyncResult } from "effect/reactivity";
23import { useRouter } from "@tanstack/react-router";34import { useRouter } from "@tanstack/react-router";
...@@ -25,13 +36,18 @@ import { useCallback, useMemo, useRef } from "react";...@@ -25,13 +36,18 @@ import { useCallback, useMemo, useRef } from "react";
2536
26import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic";37import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic";
27import { useComposerDraftStore } from "../composerDraftStore";38import { useComposerDraftStore } from "../composerDraftStore";
28import { environmentSession, readEnvironmentScope } from "../state/session";39import {
29import { terminalEnvironment } from "../state/terminal";40 environmentSession,
41 readEnvironmentScope,
42 readThreadOrganizationPermission,
43} from "../state/session";
30import { appAtomRegistry } from "../rpc/atomRegistry";44import { appAtomRegistry } from "../rpc/atomRegistry";
31import { environmentServerConfigsAtom } from "../state/server";45import { environmentServerConfigsAtom } from "../state/server";
32import { isScratchProject } from "@t3tools/client-runtime/state/projects";46import { isScratchProject } from "@t3tools/client-runtime/state/projects";
33import { threadEnvironment } from "../state/threads";47import { threadEnvironment } from "../state/threads";
34import { vcsEnvironment } from "../state/vcs";48import { vcsEnvironment } from "../state/vcs";
49import { terminalEnvironment } from "../state/terminal";
50import { previewEnvironment } from "../state/preview";
35import { useNewThreadHandler } from "./useHandleNewThread";51import { useNewThreadHandler } from "./useHandleNewThread";
36import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState";52import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState";
37import { releaseComposerDraftUploads } from "../lib/composerDraftUploads";53import { releaseComposerDraftUploads } from "../lib/composerDraftUploads";
...@@ -255,8 +271,11 @@ export function useAcknowledgeThreadWoke() {...@@ -255,8 +271,11 @@ export function useAcknowledgeThreadWoke() {
255 );271 );
256}272}
257273
258function threadOperationFailure(target: ScopedThreadRef) {274function threadOperationFailure(target: ScopedThreadRef, organization = false) {
259 return readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope)275 const permitted = organization
276 ? readThreadOrganizationPermission(target.environmentId)
277 : readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope);
278 return permitted
260 ? null279 ? null
261 : AsyncResult.failure(280 : AsyncResult.failure(
262 Cause.fail(281 Cause.fail(
...@@ -279,30 +298,39 @@ export function useThreadActions() {...@@ -279,30 +298,39 @@ export function useThreadActions() {
279 reportFailure: false,298 reportFailure: false,
280 });299 });
281 const settleThreadMutation = useOrchestrationCommand(threadEnvironment.settle, {300 const settleThreadMutation = useOrchestrationCommand(threadEnvironment.settle, {
301 organization: true,
282 reportFailure: false,302 reportFailure: false,
283 });303 });
284 const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, {304 const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, {
305 organization: true,
285 reportFailure: false,306 reportFailure: false,
286 });307 });
287 const pinThreadMutation = useOrchestrationCommand(threadEnvironment.pin, {308 const pinThreadMutation = useOrchestrationCommand(threadEnvironment.pin, {
309 organization: true,
288 reportFailure: false,310 reportFailure: false,
289 });311 });
290 const unpinThreadMutation = useOrchestrationCommand(threadEnvironment.unpin, {312 const unpinThreadMutation = useOrchestrationCommand(threadEnvironment.unpin, {
313 organization: true,
291 reportFailure: false,314 reportFailure: false,
292 });315 });
293 const setThreadAutoSettleMutation = useOrchestrationCommand(threadEnvironment.setAutoSettle, {316 const setThreadAutoSettleMutation = useOrchestrationCommand(threadEnvironment.setAutoSettle, {
317 organization: true,
294 reportFailure: false,318 reportFailure: false,
295 });319 });
296 const reorderPinnedThreadMutation = useOrchestrationCommand(threadEnvironment.reorderPin, {320 const reorderPinnedThreadMutation = useOrchestrationCommand(threadEnvironment.reorderPin, {
321 organization: true,
297 reportFailure: false,322 reportFailure: false,
298 });323 });
299 const reorderActiveThreadMutation = useOrchestrationCommand(threadEnvironment.reorderActive, {324 const reorderActiveThreadMutation = useOrchestrationCommand(threadEnvironment.reorderActive, {
325 organization: true,
300 reportFailure: false,326 reportFailure: false,
301 });327 });
302 const snoozeThreadMutation = useOrchestrationCommand(threadEnvironment.snooze, {328 const snoozeThreadMutation = useOrchestrationCommand(threadEnvironment.snooze, {
329 organization: true,
303 reportFailure: false,330 reportFailure: false,
304 });331 });
305 const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, {332 const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, {
333 organization: true,
306 reportFailure: false,334 reportFailure: false,
307 });335 });
308 const markThreadUnread = useMarkThreadUnread();336 const markThreadUnread = useMarkThreadUnread();
...@@ -374,7 +402,10 @@ export function useThreadActions() {...@@ -374,7 +402,10 @@ export function useThreadActions() {
374 );402 );
375403
376 const archiveThread = useCallback(404 const archiveThread = useCallback(
377 async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => {405 async (
406 target: ScopedThreadRef,
407 opts: { onArchived?: () => void; snapshotSequence?: number } = {},
408 ) => {
378 const resolved = resolveThreadTarget(target);409 const resolved = resolveThreadTarget(target);
379 if (!resolved) return AsyncResult.success(undefined);410 if (!resolved) return AsyncResult.success(undefined);
380 const { thread, threadRef } = resolved;411 const { thread, threadRef } = resolved;
...@@ -389,14 +420,15 @@ export function useThreadActions() {...@@ -389,14 +420,15 @@ export function useThreadActions() {
389 );420 );
390 }421 }
391422
392 const currentRouteThreadRef = getCurrentRouteThreadRef();
393 const shouldNavigateToDraft =
394 currentRouteThreadRef?.threadId === threadRef.threadId &&
395 currentRouteThreadRef.environmentId === threadRef.environmentId;
396 const action = ThreadUndo.begin("archive", scopedThreadKey(threadRef));423 const action = ThreadUndo.begin("archive", scopedThreadKey(threadRef));
397 const archiveResult = await archiveThreadMutation({424 const archiveResult = await archiveThreadMutation({
398 environmentId: threadRef.environmentId,425 environmentId: threadRef.environmentId,
399 input: { threadId: threadRef.threadId },426 input: {
427 threadId: threadRef.threadId,
428 ...(opts.snapshotSequence === undefined
429 ? {}
430 : { snapshotSequence: opts.snapshotSequence }),
431 },
400 });432 });
401 if (archiveResult._tag === "Failure") {433 if (archiveResult._tag === "Failure") {
402 action.finish();434 action.finish();
...@@ -408,25 +440,27 @@ export function useThreadActions() {...@@ -408,25 +440,27 @@ export function useThreadActions() {
408 }440 }
409 refreshArchivedThreadsForEnvironment(threadRef.environmentId);441 refreshArchivedThreadsForEnvironment(threadRef.environmentId);
410 opts.onArchived?.();442 opts.onArchived?.();
443 const currentRouteThreadRef = getCurrentRouteThreadRef();
444 const shouldNavigateToDraft =
445 currentRouteThreadRef?.threadId === threadRef.threadId &&
446 currentRouteThreadRef.environmentId === threadRef.environmentId;
447 const navigationResult = shouldNavigateToDraft
448 ? await settlePromise(() =>
449 handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)),
450 )
451 : null;
411 showThreadUndoNotice({452 showThreadUndoNotice({
412 action: "Archived",453 action: "Archived",
413 claim: action,454 claim: action,
414 // Undo also brings the reader back when archiving moved them to a draft.455 // Undo also brings the reader back when archiving moved them to a draft.
415 undo: () => unarchiveThread(threadRef, { navigate: shouldNavigateToDraft }),456 undo: () =>
457 unarchiveThread(threadRef, {
458 navigate: navigationResult?._tag === "Success" && navigationResult.value !== null,
459 }),
416 failureTitle: "Failed to undo archive",460 failureTitle: "Failed to undo archive",
417 });461 });
418462
419 if (shouldNavigateToDraft) {463 return navigationResult?._tag === "Failure" ? navigationResult : archiveResult;
420 const navigationResult = await settlePromise(() =>
421 handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)),
422 );
423 if (navigationResult._tag === "Failure") {
424 return navigationResult;
425 }
426 return archiveResult;
427 }
428
429 return archiveResult;
430 },464 },
431 [465 [
432 archiveThreadMutation,466 archiveThreadMutation,
...@@ -767,8 +801,98 @@ export function useThreadActions() {...@@ -767,8 +801,98 @@ export function useThreadActions() {
767 [pinThread, unpinThreadMutation],801 [pinThread, unpinThreadMutation],
768 );802 );
769803
804 const confirmThreadSettlement = useCallback(async (targets: readonly ScopedThreadRef[]) => {
805 for (const target of targets) {
806 const permissionFailure = threadOperationFailure(target, true);
807 if (permissionFailure) return permissionFailure;
808 if (!readEnvironmentSupportsSettlement(target.environmentId)) {
809 return AsyncResult.failure(Cause.fail(new ThreadSettlementUnsupportedError(target)));
810 }
811 }
812 if (targets.length === 0) return AsyncResult.success(false);
813 const offline = new Set(
814 targets.flatMap((target) =>
815 appAtomRegistry.get(hubConnection.organizationPermissionAtom(target.environmentId)) &&
816 Option.getOrNull(
817 AsyncResult.value(
818 appAtomRegistry.get(environmentCatalog.stateAtom(target.environmentId)),
819 ),
820 )?.phase !== "connected"
821 ? [target.environmentId]
822 : [],
823 ),
824 );
825 const metadata = new Map(
826 await Promise.all(
827 [...new Set(targets.map((target) => target.environmentId))].map(
828 async (environmentId) =>
829 [
830 environmentId,
831 offline.has(environmentId)
832 ? null
833 : await executeAtomQuery(
834 appAtomRegistry,
835 terminalEnvironment.metadataSnapshot({ environmentId, input: null }),
836 { refresh: true, reportFailure: false, signal: AbortSignal.timeout(3_000) },
837 ),
838 ] as const,
839 ),
840 ),
841 );
842 const resources = await Promise.all(
843 targets.map(async (target) => {
844 const terminals = metadata.get(target.environmentId)!;
845 const previews = offline.has(target.environmentId)
846 ? null
847 : await executeAtomQuery(
848 appAtomRegistry,
849 previewEnvironment.list({
850 environmentId: target.environmentId,
851 input: { threadId: target.threadId },
852 }),
853 { refresh: true, reportFailure: false, signal: AbortSignal.timeout(3_000) },
854 );
855 return {
856 runningCommands:
857 terminals?._tag === "Success"
858 ? terminals.value.some(
859 (terminal) =>
860 terminal.threadId === target.threadId && terminal.hasRunningSubprocess,
861 )
862 : null,
863 browserTabs: previews?._tag === "Success" ? previews.value.sessions.length > 0 : null,
864 };
865 }),
866 );
867 const warning = resolveThreadSettlementWarning({
868 runningCommands: resources.some((resource) => resource.runningCommands === null)
869 ? null
870 : resources.some((resource) => resource.runningCommands),
871 browserTabs: resources.some((resource) => resource.browserTabs === null)
872 ? null
873 : resources.some((resource) => resource.browserTabs),
874 });
875 if (warning === null) return AsyncResult.success(true);
876 const title =
877 targets.length === 1
878 ? `Settle "${readThreadShell(targets[0]!)?.title ?? "this chat"}"?`
879 : `Settle ${targets.length} chats?`;
880 const localApi = readLocalApi();
881 if (!localApi) return AsyncResult.failure(Cause.interrupt());
882 return settlePromise(() =>
883 localApi.dialogs.confirm(
884 `${title}\n${warning}${offline.size > 0 ? " Cleanup waits until the computer reconnects." : ""}`,
885 {
886 variant: "destructive",
887 },
888 ),
889 );
890 }, []);
891
770 const settleThread = useCallback(892 const settleThread = useCallback(
771 async (target: ScopedThreadRef) => {893 async (target: ScopedThreadRef, options: { confirmed?: true } = {}) => {
894 const permissionFailure = threadOperationFailure(target, true);
895 if (permissionFailure) return permissionFailure;
772 // Version skew: never send the command to a server that predates it —896 // Version skew: never send the command to a server that predates it —
773 // the raw protocol rejection would read as a random failure.897 // the raw protocol rejection would read as a random failure.
774 if (!readEnvironmentSupportsSettlement(target.environmentId)) {898 if (!readEnvironmentSupportsSettlement(target.environmentId)) {
...@@ -781,6 +905,16 @@ export function useThreadActions() {...@@ -781,6 +905,16 @@ export function useThreadActions() {
781 ),905 ),
782 );906 );
783 }907 }
908 if (readThreadShell(target)?.settledOverride === "settled") {
909 return AsyncResult.success(undefined);
910 }
911 if (!options.confirmed) {
912 const confirmation = await confirmThreadSettlement([target]);
913 if (confirmation._tag === "Failure") return confirmation;
914 if (!confirmation.value) return AsyncResult.failure(Cause.interrupt());
915 }
916 const currentPermissionFailure = threadOperationFailure(target, true);
917 if (currentPermissionFailure) return currentPermissionFailure;
784 const resolved = resolveThreadTarget(target);918 const resolved = resolveThreadTarget(target);
785 const wokeAt = resolved919 const wokeAt = resolved
786 ? threadWokeAt(resolved.thread, { now: new Date().toISOString() })920 ? threadWokeAt(resolved.thread, { now: new Date().toISOString() })
...@@ -832,6 +966,7 @@ export function useThreadActions() {...@@ -832,6 +966,7 @@ export function useThreadActions() {
832 return result;966 return result;
833 },967 },
834 [968 [
969 confirmThreadSettlement,
835 markThreadVisited,970 markThreadVisited,
836 pinThread,971 pinThread,
837 resolveThreadTarget,972 resolveThreadTarget,
...@@ -843,7 +978,7 @@ export function useThreadActions() {...@@ -843,7 +978,7 @@ export function useThreadActions() {
843978
844 const confirmAndUnpinThread = useCallback(979 const confirmAndUnpinThread = useCallback(
845 async (target: ScopedThreadRef) => {980 async (target: ScopedThreadRef) => {
846 const permissionFailure = threadOperationFailure(target);981 const permissionFailure = threadOperationFailure(target, true);
847 if (permissionFailure) return permissionFailure;982 if (permissionFailure) return permissionFailure;
848 const localApi = readLocalApi();983 const localApi = readLocalApi();
849 const resolved = resolveThreadTarget(target);984 const resolved = resolveThreadTarget(target);
...@@ -1013,6 +1148,7 @@ export function useThreadActions() {...@@ -1013,6 +1148,7 @@ export function useThreadActions() {
1013 unarchiveThread,1148 unarchiveThread,
1014 deleteThread,1149 deleteThread,
1015 confirmAndDeleteThread,1150 confirmAndDeleteThread,
1151 confirmThreadSettlement,
1016 settleThread,1152 settleThread,
1017 unsettleThread,1153 unsettleThread,
1018 snoozeThread,1154 snoozeThread,
...@@ -1029,6 +1165,7 @@ export function useThreadActions() {...@@ -1029,6 +1165,7 @@ export function useThreadActions() {
1029 archiveThread,1165 archiveThread,
1030 confirmAndDeleteThread,1166 confirmAndDeleteThread,
1031 confirmAndUnpinThread,1167 confirmAndUnpinThread,
1168 confirmThreadSettlement,
1032 deleteThread,1169 deleteThread,
1033 markThreadUnread,1170 markThreadUnread,
1034 pinThread,1171 pinThread,
apps/web/src/hooks/useThreadActions.undo.test.ts+271-2
...@@ -1,11 +1,41 @@...@@ -1,11 +1,41 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";1import {
2 CommandId,
3 EnvironmentId,
4 OrchestrationV2DispatchCommandError,
5 ThreadId,
6} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";
8import { AsyncResult } from "effect/reactivity";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";9import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
310
4import { useThreadActions } from "./useThreadActions";11import { useThreadActions } from "./useThreadActions";
5import { threadEnvironment } from "../state/threads";12import { threadEnvironment } from "../state/threads";
6import { toastManager } from "../components/ui/toast";13import { toastManager } from "../components/ui/toast";
7import { useThreadUndoNotice } from "./showThreadUndoNotice";14import { useThreadUndoNotice } from "./showThreadUndoNotice";
15import { archiveSelectedThreadEntries } from "../components/Sidebar.logic";
816
17const resourceQueries = vi.hoisted(() => ({
18 terminals: null as { threadId: string; hasRunningSubprocess: boolean }[] | null,
19 tabs: null as unknown[] | null,
20}));
21vi.mock("@t3tools/client-runtime/state/runtime", async (original) => ({
22 ...(await original<typeof import("@t3tools/client-runtime/state/runtime")>()),
23 executeAtomQuery: async (_registry: unknown, atom: string) => {
24 const value =
25 atom === "terminal-metadata" ? resourceQueries.terminals : { sessions: resourceQueries.tabs };
26 return (atom === "terminal-metadata" ? resourceQueries.terminals : resourceQueries.tabs) ===
27 null
28 ? { _tag: "Failure", cause: {} }
29 : { _tag: "Success", value };
30 },
31}));
32vi.mock("../state/terminal", () => ({
33 terminalEnvironment: { metadataSnapshot: () => "terminal-metadata" },
34}));
35vi.mock("../state/preview", () => ({ previewEnvironment: { list: () => "preview-list" } }));
36
37const confirmation = vi.hoisted(() => vi.fn(async (_message: string, _options?: unknown) => true));
38vi.mock("../localApi", () => ({ readLocalApi: () => ({ dialogs: { confirm: confirmation } }) }));
9const commands = vi.hoisted(() => ({39const commands = vi.hoisted(() => ({
10 pin: vi.fn(),40 pin: vi.fn(),
11 unpin: vi.fn(),41 unpin: vi.fn(),
...@@ -20,6 +50,12 @@ const router = vi.hoisted(() => ({...@@ -20,6 +50,12 @@ const router = vi.hoisted(() => ({
20 navigate: vi.fn(async () => {}),50 navigate: vi.fn(async () => {}),
21 state: { matches: [{ params: {} as Record<string, string> }] },51 state: { matches: [{ params: {} as Record<string, string> }] },
22}));52}));
53const newThread = vi.hoisted(() =>
54 vi.fn<() => Promise<{ draftId: string; threadId: string } | null>>(async () => ({
55 draftId: "draft",
56 threadId: "draft-thread",
57 })),
58);
23vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));59vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));
24vi.mock("../state/session", async (original) => ({60vi.mock("../state/session", async (original) => ({
25 ...(await original<typeof import("../state/session")>()),61 ...(await original<typeof import("../state/session")>()),
...@@ -33,7 +69,7 @@ vi.mock("react", async (original) => ({...@@ -33,7 +69,7 @@ vi.mock("react", async (original) => ({
33}));69}));
34vi.mock("@tanstack/react-router", () => ({ useRouter: () => router }));70vi.mock("@tanstack/react-router", () => ({ useRouter: () => router }));
35vi.mock("./useSettings", () => ({ useClientSettings: () => false }));71vi.mock("./useSettings", () => ({ useClientSettings: () => false }));
36vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() }));72vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => newThread }));
37vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() }));73vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() }));
38vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() }));74vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() }));
39vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() }));75vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() }));
...@@ -43,6 +79,7 @@ const threadShell = vi.hoisted(() => ({...@@ -43,6 +79,7 @@ const threadShell = vi.hoisted(() => ({
43 pinOrderKey: "a0",79 pinOrderKey: "a0",
44 pinnedAt: null as string | null,80 pinnedAt: null as string | null,
45 snoozedUntil: null as string | null,81 snoozedUntil: null as string | null,
82 settledOverride: null as "settled" | null,
46 projectId: "project",83 projectId: "project",
47 environmentId: "undo-env",84 environmentId: "undo-env",
48 session: null,85 session: null,
...@@ -91,14 +128,19 @@ function currentUndo() {...@@ -91,14 +128,19 @@ function currentUndo() {
91}128}
92129
93beforeEach(() => {130beforeEach(() => {
131 resourceQueries.terminals = null;
132 resourceQueries.tabs = null;
94 vi.useFakeTimers();133 vi.useFakeTimers();
134 confirmation.mockReset().mockResolvedValue(true);
95 for (const command of Object.values(commands)) {135 for (const command of Object.values(commands)) {
96 command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined });136 command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined });
97 }137 }
98 router.navigate.mockClear();138 router.navigate.mockClear();
139 newThread.mockReset().mockResolvedValue({ draftId: "draft", threadId: "draft-thread" });
99 router.state.matches[0]!.params = {};140 router.state.matches[0]!.params = {};
100 threadShell.pinnedAt = null;141 threadShell.pinnedAt = null;
101 threadShell.snoozedUntil = null;142 threadShell.snoozedUntil = null;
143 threadShell.settledOverride = null;
102});144});
103afterEach(() => {145afterEach(() => {
104 vi.runAllTimers();146 vi.runAllTimers();
...@@ -129,6 +171,134 @@ describe("unpin Undo", () => {...@@ -129,6 +171,134 @@ describe("unpin Undo", () => {
129});171});
130172
131describe("archive Undo", () => {173describe("archive Undo", () => {
174 it("keeps a rejected current thread in place and groups Undo only for successful candidates", async () => {
175 const entries = ["first", target.threadId, "last"].map((id) => ({
176 threadRef: { ...target, threadId: ThreadId.make(id) },
177 threadKey: `${target.environmentId}:${id}`,
178 }));
179 const changed = AsyncResult.failure(
180 Cause.fail(
181 new OrchestrationV2DispatchCommandError({
182 commandId: CommandId.make("archive-condition-rejected"),
183 commandType: "thread.archive",
184 reason: "archive-condition-changed",
185 message: "The thread changed before automatic archival.",
186 }),
187 ),
188 );
189 commands.archive.mockResolvedValueOnce(AsyncResult.success(undefined));
190 commands.archive.mockResolvedValueOnce(changed);
191 commands.archive.mockResolvedValueOnce(AsyncResult.success(undefined));
192 router.state.matches[0]!.params = {
193 environmentId: target.environmentId,
194 threadId: target.threadId,
195 };
196 const { archiveThread } = useThreadActions();
197 const outcome = await archiveSelectedThreadEntries({
198 entries,
199 archive: ({ threadRef }, onArchived) =>
200 archiveThread(threadRef, { onArchived, snapshotSequence: 7 }),
201 });
202
203 expect(outcome.archivedThreadKeys).toEqual([entries[0]!.threadKey, entries[2]!.threadKey]);
204 expect(newThread).not.toHaveBeenCalled();
205 expect(router.navigate).not.toHaveBeenCalled();
206 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Archived", count: 2 });
207
208 await currentUndo()();
209
210 expect(commands.unarchive.mock.calls.map(([request]) => request.input.threadId)).toEqual([
211 "last",
212 "first",
213 ]);
214 expect(router.navigate).not.toHaveBeenCalled();
215 });
216
217 it("preserves navigation and Undo stays put when the reader leaves during archive", async () => {
218 let completeArchive!: () => void;
219 const pending = new Promise<{ _tag: "Success"; value: undefined }>((resolve) => {
220 completeArchive = () => resolve({ _tag: "Success", value: undefined });
221 });
222 commands.archive.mockReturnValueOnce(pending);
223 router.state.matches[0]!.params = {
224 environmentId: target.environmentId,
225 threadId: target.threadId,
226 };
227 const archiving = useThreadActions().archiveThread(target);
228 expect(commands.archive).toHaveBeenCalledOnce();
229 router.state.matches[0]!.params = {
230 environmentId: target.environmentId,
231 threadId: "another-thread",
232 };
233 completeArchive();
234 await archiving;
235
236 expect(newThread).not.toHaveBeenCalled();
237 expect(router.navigate).not.toHaveBeenCalled();
238 await currentUndo()();
239 expect(commands.unarchive).toHaveBeenCalledOnce();
240 expect(router.navigate).not.toHaveBeenCalled();
241 expect(router.state.matches[0]!.params.threadId).toBe("another-thread");
242 });
243
244 it("keeps archive Undo without return navigation when leaving the archived thread failed", async () => {
245 router.state.matches[0]!.params = {
246 environmentId: target.environmentId,
247 threadId: target.threadId,
248 };
249 newThread.mockRejectedValueOnce(new Error("Navigation failed"));
250 const result = await useThreadActions().archiveThread(target);
251
252 expect(result._tag).toBe("Failure");
253 expect(newThread).toHaveBeenCalledOnce();
254 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Archived", count: 1 });
255 await currentUndo()();
256 expect(commands.unarchive).toHaveBeenCalledOnce();
257 expect(router.navigate).not.toHaveBeenCalled();
258 });
259
260 it("does not return on Undo when draft creation declined navigation", async () => {
261 router.state.matches[0]!.params = {
262 environmentId: target.environmentId,
263 threadId: target.threadId,
264 };
265 newThread.mockResolvedValueOnce(null);
266 const result = await useThreadActions().archiveThread(target);
267
268 expect(result._tag).toBe("Success");
269 expect(newThread).toHaveBeenCalledOnce();
270 await currentUndo()();
271 expect(commands.unarchive).toHaveBeenCalledOnce();
272 expect(router.navigate).not.toHaveBeenCalled();
273 });
274
275 it("preserves each archive snapshot guard and restores a bulk notice together", async () => {
276 const actions = useThreadActions();
277 const second = { ...target, threadId: ThreadId.make("second-thread") };
278 await actions.archiveThread(target, { snapshotSequence: 17 });
279 await actions.archiveThread(second, { snapshotSequence: 42 });
280
281 expect(commands.archive).toHaveBeenNthCalledWith(1, {
282 environmentId: target.environmentId,
283 input: { threadId: target.threadId, snapshotSequence: 17 },
284 });
285 expect(commands.archive).toHaveBeenNthCalledWith(2, {
286 environmentId: second.environmentId,
287 input: { threadId: second.threadId, snapshotSequence: 42 },
288 });
289 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Archived", count: 2 });
290 await currentUndo()();
291 expect(commands.unarchive).toHaveBeenCalledTimes(2);
292 expect(commands.unarchive).toHaveBeenCalledWith({
293 environmentId: target.environmentId,
294 input: { threadId: target.threadId },
295 });
296 expect(commands.unarchive).toHaveBeenCalledWith({
297 environmentId: second.environmentId,
298 input: { threadId: second.threadId },
299 });
300 });
301
132 it("unarchives and returns to the thread when archiving left it", async () => {302 it("unarchives and returns to the thread when archiving left it", async () => {
133 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");303 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
134 router.state.matches[0]!.params = {304 router.state.matches[0]!.params = {
...@@ -169,6 +339,105 @@ describe("archive Undo", () => {...@@ -169,6 +339,105 @@ describe("archive Undo", () => {
169});339});
170340
171describe("settle and snooze Undo", () => {341describe("settle and snooze Undo", () => {
342 it.each([
343 { terminals: [] },
344 { terminals: [{ threadId: "thread", hasRunningSubprocess: false }] },
345 ])("settles empty or shell-only terminals without confirmation", async ({ terminals }) => {
346 resourceQueries.terminals = terminals;
347 resourceQueries.tabs = [];
348 await useThreadActions().settleThread(target);
349 expect(confirmation).not.toHaveBeenCalled();
350 expect(commands.settle).toHaveBeenCalledOnce();
351 });
352
353 it.each([
354 {
355 terminals: [{ threadId: "thread", hasRunningSubprocess: true }],
356 tabs: [],
357 text: "Undo cannot restart them.",
358 },
359 { terminals: [], tabs: [{}], text: "Undo cannot restore page state." },
360 { terminals: null, tabs: [], text: "could not be checked" },
361 ])("warns accurately before settlement: $text", async ({ terminals, tabs, text }) => {
362 resourceQueries.terminals = terminals;
363 resourceQueries.tabs = tabs;
364 confirmation.mockResolvedValue(false);
365 await useThreadActions().settleThread(target);
366 expect(confirmation).toHaveBeenCalledExactlyOnceWith(expect.stringContaining(text), {
367 variant: "destructive",
368 });
369 expect(commands.settle).not.toHaveBeenCalled();
370 });
371
372 it("ignores commands belonging to a different chat", async () => {
373 resourceQueries.terminals = [{ threadId: "other-thread", hasRunningSubprocess: true }];
374 resourceQueries.tabs = [];
375 await useThreadActions().settleThread(target);
376 expect(confirmation).not.toHaveBeenCalled();
377 expect(commands.settle).toHaveBeenCalledOnce();
378 });
379
380 it("cancels without changing the chat or invalidating its existing Undo", async () => {
381 const actions = useThreadActions();
382 await actions.unpinThread(target);
383 const undo = currentUndo();
384 confirmation.mockResolvedValue(false);
385
386 const result = await actions.settleThread(target);
387
388 expect(result._tag).toBe("Failure");
389 expect(commands.settle).not.toHaveBeenCalled();
390 await undo();
391 expect(commands.pin).toHaveBeenCalledOnce();
392 expect(confirmation).toHaveBeenCalledExactlyOnceWith(
393 expect.stringContaining("Undo cannot restore commands or page state"),
394 { variant: "destructive" },
395 );
396 });
397
398 it("waits for confirmation before dispatching settlement", async () => {
399 let confirm!: (allowed: boolean) => void;
400 confirmation.mockImplementation(
401 () =>
402 new Promise((resolve) => {
403 confirm = resolve;
404 }),
405 );
406 const pending = useThreadActions().settleThread(target);
407 expect(commands.settle).not.toHaveBeenCalled();
408
409 await vi.waitFor(() => expect(confirmation).toHaveBeenCalledOnce());
410 confirm(true);
411 await pending;
412
413 expect(commands.settle).toHaveBeenCalledOnce();
414 });
415
416 it("does not warn or settle again when the chat is already settled", async () => {
417 threadShell.settledOverride = "settled";
418
419 await useThreadActions().settleThread(target);
420
421 expect(confirmation).not.toHaveBeenCalled();
422 expect(commands.settle).not.toHaveBeenCalled();
423 });
424
425 it("warns once before a confirmed batch and never again during Undo", async () => {
426 const actions = useThreadActions();
427 const second = { ...target, threadId: ThreadId.make("second-thread") };
428 const result = await actions.confirmThreadSettlement([target, second]);
429 expect(result).toMatchObject({ _tag: "Success", value: true });
430 expect(commands.settle).not.toHaveBeenCalled();
431
432 await actions.settleThread(target, { confirmed: true });
433 await actions.settleThread(second, { confirmed: true });
434 await currentUndo()();
435
436 expect(confirmation).toHaveBeenCalledOnce();
437 expect(confirmation.mock.calls[0]?.[0]).toContain("Settle 2 chats?");
438 expect(commands.settle).toHaveBeenCalledTimes(2);
439 });
440
172 it("un-settles from the notice and expires the Undo after a manual un-settle", async () => {441 it("un-settles from the notice and expires the Undo after a manual un-settle", async () => {
173 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");442 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
174 const actions = useThreadActions();443 const actions = useThreadActions();
apps/web/src/state/managedWorkspaces.ts created+25
...@@ -0,0 +1,25 @@
1import {
2 createEnvironmentRpcCommand,
3 createEnvironmentRpcQueryAtomFamily,
4} from "@t3tools/client-runtime/state/runtime";
5import { WS_METHODS, type EnvironmentId } from "@t3tools/contracts";
6import { Atom } from "effect/reactivity";
7
8import { connectionAtomRuntime } from "../connection/runtime";
9
10export const managedWorkspaceStatus = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, {
11 label: "environment-data:managed-workspaces:status",
12 tag: WS_METHODS.managedWorkspacesStatus,
13 staleTimeMs: 30_000,
14 idleTtlMs: 60_000,
15});
16
17export const createManagedWorkspace = createEnvironmentRpcCommand(connectionAtomRuntime, {
18 label: "environment-data:managed-workspaces:create",
19 tag: WS_METHODS.managedWorkspacesCreate,
20});
21
22export const managedWorkspacePending = Atom.family<
23 EnvironmentId | null,
24 Atom.Writable<boolean, boolean>
25>(() => Atom.make(false));
docs/user/thread-sidebar.md+34-9
...@@ -130,17 +130,39 @@ Choose **Settle thread** from its menu to move finished work out of the active l...@@ -130,17 +130,39 @@ Choose **Settle thread** from its menu to move finished work out of the active l
130without deleting the conversation. **Un-settle thread** restores it to active work130without deleting the conversation. **Un-settle thread** restores it to active work
131and prevents automatic settlement until new activity resumes the usual rules.131and prevents automatic settlement until new activity resumes the usual rules.
132Manually settling an idle thread dismisses unanswered async questions without132Manually settling an idle thread dismisses unanswered async questions without
133sending an answer or restarting the agent. Settling also closes the thread's133sending an answer or restarting the agent. Manual settlement closes all of the
134terminals that wait at an idle prompt, and keeps their output. A terminal that134thread's terminals and browser tabs, including running commands and playing media.
135runs a command, such as a dev server, stays open.135Running programs or open browser tabs ask for confirmation; empty chats and idle
136shells settle directly. Terminal output stays in the conversation. Undo or
137unsettling restores the chat, but cannot restart those commands or restore its tabs.
138Other chats keep their own terminals and tabs, including in a shared workspace.
139The workspace's detached dev server stops once no
140active chat or pending work needs it; shared workspaces keep their server while
141another chat is active.
136142
137To reclaim disk space from settled work, turn on **Run in the thread's worktree when the143To reclaim disk space from settled work, turn on **Run in the thread's worktree when the
138thread settles** for one of the project's actions, or set `"runOnSettle": true` on a144thread settles** for one of the project's actions, or set `"runOnSettle": true` on a
139`t3.json` script, for example `cargo clean`. It runs each time a thread in its own145`t3.json` script, for example `cargo clean`. It runs each time a thread in its own
140worktree settles, manually or automatically, even if a terminal there still runs a146worktree settles, manually or automatically. Threads in the project's main checkout skip it. Its terminal
141command such as a dev server. Threads in the project's main checkout skip it. Its terminal
142closes when the command succeeds and stays open when it fails.147closes when the command succeeds and stays open when it fails.
143148
149Settled threads archive on Saturday at 9:00 AM in the server's local time. If the
150server was offline, it catches up when it starts. Threads with running work or
151pending requests wait until that work finishes.
152
153On web and desktop, right-click **Settled** and choose **Archive all** to archive
154the settled chats in the current project filter, including hidden chats. To archive
155a selection, choose **Archive** from its context menu. Undo restores each archived
156group together. Chats that resume work during Archive all stay available.
157
158Workspaces for my work stay available while their chats are settled; settlement
159does not run a cleanup script there. A settled work chat archives once all its
160linked pull requests merge, including when you settle it after the merge.
161Workspace cleanup waits for archival and for other chats or external tools
162to release the folder. Mergebot supplies live PR and check updates. A new CI failure returns the chat to Active and adds a
163Mergebot link to the transcript without starting an agent. Settling it again
164acknowledges that failure; a failed rerun can wake it again.
165
144On web and desktop, press a thread's **Settle** button and drag up or down to166On web and desktop, press a thread's **Settle** button and drag up or down to
145settle every thread in that section between it and the one you release on.167settle every thread in that section between it and the one you release on.
146The **Un-settle** and **Wake** buttons work the same way in their sections.168The **Un-settle** and **Wake** buttons work the same way in their sections.
...@@ -148,12 +170,15 @@ Press `Escape` while dragging to cancel....@@ -148,12 +170,15 @@ Press `Escape` while dragging to cancel.
148170
149By default, environments settle inactive threads after three days and settle171By default, environments settle inactive threads after three days and settle
150threads whose pull request merged. A closed pull request can also settle an idle172threads whose pull request merged. A closed pull request can also settle an idle
151thread. Work in progress, pending questions or approvals, and live background work173thread. Work in progress, pending questions or approvals, live background work,
152prevent automatic settlement. An open pull request does not prevent inactivity174and open terminals or browser tabs prevent automatic settlement. An open pull
153settlement unless you turn on **Keep threads with a PR** in **Settings → General**,175request does not prevent inactivity settlement unless you turn on **Keep threads
154but an old closed or merged pull request does not settle work you176with a PR** in **Settings → General**, but an old closed or merged pull request
177does not settle work you
155resumed after it closed. Only your own messages count as resuming. A turn that178resumed after it closed. Only your own messages count as resuming. A turn that
156finished background work or a pull request watch starts on its own does not.179finished background work or a pull request watch starts on its own does not.
180Disconnected or unverified Mergebot data cannot trigger merge archival, PR-based
181settlement, or a CI wakeup. Scheduled archival and inactivity rules still apply.
157182
158To keep one thread out of the settled shelf no matter how long it sits idle, open its menu,183To keep one thread out of the settled shelf no matter how long it sits idle, open its menu,
159choose **Auto-settle behavior**, and pick **Disabled**. The current option is checked. Pick184choose **Auto-settle behavior**, and pick **Disabled**. The current option is checked. Pick
packages/client-runtime/src/operations/commands.test.ts+20
...@@ -234,6 +234,26 @@ describe("V2 environment commands", () => {...@@ -234,6 +234,26 @@ describe("V2 environment commands", () => {
234 }).pipe(Effect.provide(layerTestCrypto)),234 }).pipe(Effect.provide(layerTestCrypto)),
235 );235 );
236236
237 it.effect("preserves the settled shelf snapshot guard when archiving", () =>
238 Effect.gen(function* () {
239 const commands: OrchestrationV2Command[] = [];
240 const supervisor = yield* makeSupervisor({ commands, projects: [] });
241 yield* archiveThread({
242 commandId: CommandId.make("guarded-archive"),
243 threadId: ThreadId.make("settled-thread"),
244 snapshotSequence: 137,
245 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
246 expect(commands).toEqual([
247 {
248 type: "thread.archive",
249 commandId: "guarded-archive",
250 threadId: "settled-thread",
251 snapshotSequence: 137,
252 },
253 ]);
254 }).pipe(Effect.provide(layerTestCrypto)),
255 );
256
237 it.effect("resolves run ordinal zero to the persisted thread-start checkpoint", () =>257 it.effect("resolves run ordinal zero to the persisted thread-start checkpoint", () =>
238 Effect.gen(function* () {258 Effect.gen(function* () {
239 const scopeId = CheckpointScopeId.make("checkpoint-scope-root");259 const scopeId = CheckpointScopeId.make("checkpoint-scope-root");
packages/client-runtime/src/operations/commands.ts+9-2
...@@ -90,7 +90,9 @@ export interface ThreadCommandInput extends CommandMetadata {...@@ -90,7 +90,9 @@ export interface ThreadCommandInput extends CommandMetadata {
90}90}
9191
92export type DeleteThreadInput = ThreadCommandInput;92export type DeleteThreadInput = ThreadCommandInput;
93export type ArchiveThreadInput = ThreadCommandInput;93export interface ArchiveThreadInput extends ThreadCommandInput {
94 readonly snapshotSequence?: number;
95}
94export type UnarchiveThreadInput = ThreadCommandInput;96export type UnarchiveThreadInput = ThreadCommandInput;
95export type SettleThreadInput = ThreadCommandInput;97export type SettleThreadInput = ThreadCommandInput;
9698
...@@ -458,7 +460,12 @@ export const deleteThread = Effect.fn("EnvironmentCommands.deleteThread")(functi...@@ -458,7 +460,12 @@ export const deleteThread = Effect.fn("EnvironmentCommands.deleteThread")(functi
458export const archiveThread = Effect.fn("EnvironmentCommands.archiveThread")(function* (460export const archiveThread = Effect.fn("EnvironmentCommands.archiveThread")(function* (
459 input: ArchiveThreadInput,461 input: ArchiveThreadInput,
460) {462) {
461 return yield* simpleThreadCommand("thread.archive", input);463 return yield* dispatch({
464 type: "thread.archive",
465 commandId: yield* allocateCommandId(input),
466 threadId: input.threadId,
467 ...(input.snapshotSequence === undefined ? {} : { snapshotSequence: input.snapshotSequence }),
468 });
462});469});
463470
464export const unarchiveThread = Effect.fn("EnvironmentCommands.unarchiveThread")(function* (471export const unarchiveThread = Effect.fn("EnvironmentCommands.unarchiveThread")(function* (
packages/client-runtime/src/rpc/client.test.ts+28
...@@ -33,6 +33,7 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts";...@@ -33,6 +33,7 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
33import {33import {
34 EnvironmentRpcRequestObserver,34 EnvironmentRpcRequestObserver,
35 request,35 request,
36 requestGuarded,
36 runStream,37 runStream,
37 subscribe,38 subscribe,
38 subscribeDynamicWithSession,39 subscribeDynamicWithSession,
...@@ -89,6 +90,33 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () {...@@ -89,6 +90,33 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () {
89});90});
9091
91describe("environment RPC", () => {92describe("environment RPC", () => {
93 it.effect("reads managed workspace status without a command boundary and denies creation", () =>
94 Effect.gen(function* () {
95 let creates = 0;
96 const client = {
97 [WS_METHODS.managedWorkspacesStatus]: () => Effect.succeed({ projectId: null }),
98 [WS_METHODS.managedWorkspacesCreate]: () => {
99 creates += 1;
100 return Effect.die("Protected workspace creation reached the transport");
101 },
102 } as unknown as WsRpcProtocolClient;
103 const { activeSession, supervisor } = yield* makeHarness();
104 yield* SubscriptionRef.set(activeSession, Option.some(session(client)));
105
106 const status = yield* request(WS_METHODS.managedWorkspacesStatus, {}).pipe(
107 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
108 );
109 expect(status).toEqual({ projectId: null });
110
111 const create = yield* requestGuarded(WS_METHODS.managedWorkspacesCreate, {}).pipe(
112 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
113 Effect.flip,
114 );
115 expect(create._tag).toBe("EnvironmentAuthorizationError");
116 expect(creates).toBe(0);
117 }),
118 );
119
92 it.effect("reuses the session config stream instead of opening a duplicate subscription", () =>120 it.effect("reuses the session config stream instead of opening a duplicate subscription", () =>
93 Effect.gen(function* () {121 Effect.gen(function* () {
94 const event: ServerConfigStreamEvent = {122 const event: ServerConfigStreamEvent = {
packages/client-runtime/src/state/commandPermissions.test.ts+18
...@@ -86,6 +86,24 @@ describe("command permissions", () => {...@@ -86,6 +86,24 @@ describe("command permissions", () => {
86 ),86 ),
87 );87 );
8888
89 it.effect("checks destination grants before creating a managed workspace", () =>
90 Effect.scoped(
91 Effect.gen(function* () {
92 const registry = yield* setup;
93 const create = createCommandPermissions(runtime, WS_METHODS.managedWorkspacesCreate);
94 registry.set(sessions(env), AsyncResult.success(grant(false)));
95 expect(registry.get(create.permissionAtom(env))).toBe(false);
96 expect((yield* create.authorize(registry, env).pipe(Effect.flip))._tag).toBe(
97 "EnvironmentAuthorizationError",
98 );
99 registry.set(sessions(env), AsyncResult.success(grant(true)));
100 yield* create.authorize(registry, env);
101 registry.set(sessions(other), AsyncResult.success(grant(false)));
102 expect(registry.get(create.permissionAtom(other))).toBe(false);
103 }),
104 ),
105 );
106
89 it.effect("requires destination read grants for dev status and terminal authority to start", () =>107 it.effect("requires destination read grants for dev status and terminal authority to start", () =>
90 Effect.scoped(108 Effect.scoped(
91 Effect.gen(function* () {109 Effect.gen(function* () {
packages/client-runtime/src/state/terminal.test.ts created+108
...@@ -0,0 +1,108 @@
1import { expect, it } from "@effect/vitest";
2import { EnvironmentId, type TerminalMetadataStreamEvent, WS_METHODS } from "@t3tools/contracts";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";
7import * as Stream from "effect/Stream";
8import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
9import { vi } from "vite-plus/test";
10
11import { EnvironmentRegistry } from "../connection/registry.ts";
12import { createTerminalEnvironmentAtoms } from "./terminal.ts";
13import { executeAtomQuery } from "./runtime.ts";
14
15const source = vi.hoisted(() => ({ stream: undefined as unknown }));
16vi.mock("../rpc/client.ts", async (original) => ({
17 ...(await original<typeof import("../rpc/client.ts")>()),
18 subscribe: (tag: string) => {
19 expect(tag).toBe(WS_METHODS.subscribeTerminalMetadata);
20 return source.stream;
21 },
22}));
23
24it.effect("waits for the server metadata snapshot before reporting an empty terminal list", () =>
25 Effect.scoped(
26 Effect.gen(function* () {
27 const subscribed = yield* Deferred.make<void>();
28 const snapshot = yield* Deferred.make<TerminalMetadataStreamEvent>();
29 source.stream = Stream.fromEffect(Deferred.succeed(subscribed, undefined)).pipe(
30 Stream.flatMap(() => Stream.fromEffect(Deferred.await(snapshot))),
31 );
32 const runtime = Atom.runtime(
33 Layer.succeed(EnvironmentRegistry, {
34 followStream: (_id, stream) => Stream.concat(stream, Stream.never),
35 } as EnvironmentRegistry["Service"]),
36 );
37 const terminals = createTerminalEnvironmentAtoms(runtime);
38 const registry = AtomRegistry.make();
39 yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
40 const atom = terminals.metadata({
41 environmentId: EnvironmentId.make("metadata-proof"),
42 input: null,
43 });
44 const read = yield* Effect.forkChild(AtomRegistry.getResult(registry, atom));
45 yield* Deferred.await(subscribed);
46 expect(AsyncResult.isSuccess(registry.get(atom))).toBe(false);
47 yield* Deferred.succeed(snapshot, { type: "snapshot", terminals: [] });
48 expect(yield* Fiber.join(read)).toEqual([]);
49 }),
50 ),
51);
52
53it.effect("refreshes terminal metadata while an existing subscriber keeps the stream mounted", () =>
54 Effect.scoped(
55 Effect.gen(function* () {
56 let subscriptions = 0;
57 let current: TerminalMetadataStreamEvent = { type: "snapshot", terminals: [] };
58 source.stream = Stream.suspend(() => {
59 subscriptions++;
60 return Stream.concat(Stream.succeed(current), Stream.never);
61 });
62 const runtime = Atom.runtime(
63 Layer.succeed(EnvironmentRegistry, {
64 followStream: (_id, stream) => Stream.concat(stream, Stream.never),
65 } as EnvironmentRegistry["Service"]),
66 );
67 const terminals = createTerminalEnvironmentAtoms(runtime);
68 const registry = AtomRegistry.make();
69 yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
70 const target = { environmentId: EnvironmentId.make("metadata-refresh"), input: null };
71 const live = terminals.metadata(target);
72 yield* AtomRegistry.mount(registry, live);
73 expect(yield* AtomRegistry.getResult(registry, live)).toEqual([]);
74 const atom = terminals.metadataSnapshot(target);
75 const initial = yield* Effect.promise(() =>
76 executeAtomQuery(registry, atom, { refresh: true }),
77 );
78 expect(initial._tag).toBe("Success");
79 if (initial._tag === "Success") expect(initial.value).toEqual([]);
80 const subscriptionsBeforeRefresh = subscriptions;
81 current = {
82 type: "snapshot",
83 terminals: [
84 {
85 threadId: "thread",
86 terminalId: "term",
87 cwd: "/tmp",
88 worktreePath: null,
89 status: "running",
90 pid: 42,
91 exitCode: null,
92 exitSignal: null,
93 hasRunningSubprocess: true,
94 label: "sleep",
95 updatedAt: "2026-10-15T00:00:00.000Z",
96 },
97 ],
98 };
99 const result = yield* Effect.promise(() =>
100 executeAtomQuery(registry, atom, { refresh: true }),
101 );
102 expect(result._tag).toBe("Success");
103 if (result._tag === "Success") expect(result.value).toEqual(current.terminals);
104 expect(subscriptions).toBeGreaterThan(subscriptionsBeforeRefresh);
105 expect(registry.get(live)).toMatchObject({ _tag: "Success", waiting: true, value: [] });
106 }),
107 ),
108);
packages/client-runtime/src/state/terminal.ts+10
...@@ -64,6 +64,16 @@ export function createTerminalEnvironmentAtoms<R, E>(...@@ -64,6 +64,16 @@ export function createTerminalEnvironmentAtoms<R, E>(
64 subscribe: (_input: null) =>64 subscribe: (_input: null) =>
65 subscribe(WS_METHODS.subscribeTerminalMetadata, {}).pipe(65 subscribe(WS_METHODS.subscribeTerminalMetadata, {}).pipe(
66 Stream.scan((): ReadonlyArray<TerminalSummary> => [], applyTerminalMetadataStreamEvent),66 Stream.scan((): ReadonlyArray<TerminalSummary> => [], applyTerminalMetadataStreamEvent),
67 Stream.drop(1),
68 ),
69 }),
70 metadataSnapshot: createEnvironmentSubscriptionAtomFamily(runtime, {
71 label: "environment-data:terminal:metadata-snapshot",
72 completeWhen: () => true,
73 subscribe: (_input: null) =>
74 subscribe(WS_METHODS.subscribeTerminalMetadata, {}).pipe(
75 Stream.filter((event) => event.type === "snapshot"),
76 Stream.map((event) => event.terminals),
67 ),77 ),
68 }),78 }),
69 open: createEnvironmentRpcCommand(runtime, {79 open: createEnvironmentRpcCommand(runtime, {
packages/client-runtime/src/state/threadSettled.test.ts created+34
...@@ -0,0 +1,34 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveThreadSettlementWarning } from "./threadSettled.ts";
4
5describe("settlement resource warning", () => {
6 it("skips confirmation when no commands or browser tabs need closing", () => {
7 expect(
8 resolveThreadSettlementWarning({ runningCommands: false, browserTabs: false }),
9 ).toBeNull();
10 });
11
12 it.each([
13 { runningCommands: true, browserTabs: false, text: "Undo cannot restart them." },
14 { runningCommands: false, browserTabs: true, text: "Undo cannot restore page state." },
15 {
16 runningCommands: true,
17 browserTabs: true,
18 text: "Undo cannot restore commands or page state.",
19 },
20 ])(
21 "describes only the resources being closed: $runningCommands/$browserTabs",
22 ({ text, ...resources }) => {
23 expect(resolveThreadSettlementWarning(resources)).toContain(text);
24 },
25 );
26
27 it.each([
28 { runningCommands: null, browserTabs: false },
29 { runningCommands: false, browserTabs: null },
30 { runningCommands: null, browserTabs: null },
31 ])("never treats an unavailable resource read as empty", (resources) => {
32 expect(resolveThreadSettlementWarning(resources)).toContain("could not be checked");
33 });
34});
packages/client-runtime/src/state/threadSettled.ts+17
...@@ -3,6 +3,23 @@ import * as DateTime from "effect/DateTime";...@@ -3,6 +3,23 @@ import * as DateTime from "effect/DateTime";
33
4import type { PendingSubagentRequest } from "./models.ts";4import type { PendingSubagentRequest } from "./models.ts";
55
6export function resolveThreadSettlementWarning(resources: {
7 readonly runningCommands: boolean | null;
8 readonly browserTabs: boolean | null;
9}): string | null {
10 const { runningCommands, browserTabs } = resources;
11 if (runningCommands === false && browserTabs === false) return null;
12 if (runningCommands === null || browserTabs === null) {
13 return "Some terminals or browser tabs could not be checked. Settling closes them and stops any running commands. Undo cannot restore commands or page state.";
14 }
15 if (runningCommands && browserTabs) {
16 return "Settling stops running commands and closes browser tabs. Undo cannot restore commands or page state.";
17 }
18 return runningCommands
19 ? "Settling stops running commands. Undo cannot restart them."
20 : "Settling closes browser tabs. Undo cannot restore page state.";
21}
22
6interface SettlementRunLike {23interface SettlementRunLike {
7 readonly turnId?: unknown;24 readonly turnId?: unknown;
8 readonly assistantMessageId?: unknown;25 readonly assistantMessageId?: unknown;
packages/contracts/src/clientRpcPermissions.ts+2
...@@ -17,6 +17,8 @@ export const CLIENT_GUARDED_RPC_SCOPES = {...@@ -17,6 +17,8 @@ export const CLIENT_GUARDED_RPC_SCOPES = {
17 [WS_METHODS.uiSettingsPatch]: AuthSettingsWriteScope,17 [WS_METHODS.uiSettingsPatch]: AuthSettingsWriteScope,
18 [WS_METHODS.devServerStatus]: AuthFilesystemReadScope,18 [WS_METHODS.devServerStatus]: AuthFilesystemReadScope,
19 [WS_METHODS.devServerStart]: AuthTerminalOperateScope,19 [WS_METHODS.devServerStart]: AuthTerminalOperateScope,
20 [WS_METHODS.managedWorkspacesCreate]: AuthOrchestrationOperateScope,
21 [WS_METHODS.managedWorkspacesArchiveCompleted]: AuthOrchestrationOperateScope,
20 [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope,22 [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope,
21 // Endpoint checks exist to configure exports, so they need the same grant as saving them.23 // Endpoint checks exist to configure exports, so they need the same grant as saving them.
22 [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope,24 [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope,
packages/contracts/src/index.ts+1
...@@ -66,6 +66,7 @@ export * from "./worktreeSetup.ts";...@@ -66,6 +66,7 @@ export * from "./worktreeSetup.ts";
66export * from "./secretRequest.ts";66export * from "./secretRequest.ts";
67export * from "./clientRpcPermissions.ts";67export * from "./clientRpcPermissions.ts";
68export * from "./devServer.ts";68export * from "./devServer.ts";
69export * from "./managedWorkspaces.ts";
69export * from "./mergebot.ts";70export * from "./mergebot.ts";
70export * from "./scriptProgress.ts";71export * from "./scriptProgress.ts";
71export * from "./desktopTerminal.ts";72export * from "./desktopTerminal.ts";
packages/contracts/src/managedWorkspaces.ts created+40
...@@ -0,0 +1,40 @@
1import * as Schema from "effect/Schema";
2import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
3
4export const ManagedWorkspaceStatus = Schema.Struct({ projectId: Schema.NullOr(ProjectId) });
5export const ManagedWorkspaceCreated = Schema.Struct({
6 workspacePath: TrimmedNonEmptyString,
7 projectId: ProjectId,
8 threadId: ThreadId,
9 environmentId: EnvironmentId,
10});
11export type ManagedWorkspaceCreated = typeof ManagedWorkspaceCreated.Type;
12
13export const ManagedWorkspaceArchiveInput = Schema.Struct({ workspacePath: TrimmedNonEmptyString });
14export const ManagedWorkspaceArchived = Schema.Struct({
15 archivedThreadIds: Schema.Array(ThreadId),
16});
17export type ManagedWorkspaceArchived = typeof ManagedWorkspaceArchived.Type;
18
19export class ManagedWorkspaceError extends Schema.TaggedError<ManagedWorkspaceError>()(
20 "ManagedWorkspaceError",
21 {
22 reason: Schema.Literals(["unavailable", "create", "reply", "workspace", "archive"]),
23 cause: Schema.optional(Schema.Defect()),
24 },
25) {
26 override get message(): string {
27 switch (this.reason) {
28 case "workspace":
29 return "Workspace cleanup is paused. Check the managed workspace on this host.";
30 case "archive":
31 return "Workspace archival is paused. Check its linked pull requests and retry.";
32 case "unavailable":
33 return "Workspace creation is unavailable. Install ws, configure the source for my work, and add that source to T3 Code on this host.";
34 case "create":
35 return "Workspace creation stopped. Run ws new on this host for details.";
36 case "reply":
37 return "The workspace tool returned an unreadable reply. Update ws on this host before trying again.";
38 }
39 }
40}
packages/contracts/src/orchestrationV2.test.ts+17
...@@ -29,6 +29,7 @@ import {...@@ -29,6 +29,7 @@ import {
29 OrchestrationV2Command,29 OrchestrationV2Command,
30 OrchestrationV2LimitRecoveryUpdate,30 OrchestrationV2LimitRecoveryUpdate,
31 OrchestrationV2DomainEvent,31 OrchestrationV2DomainEvent,
32 OrchestrationV2DispatchCommandError,
32 OrchestrationV2ProviderCapabilities,33 OrchestrationV2ProviderCapabilities,
33 OrchestrationV2ProviderThread,34 OrchestrationV2ProviderThread,
34 OrchestrationV2ProviderThreadJson,35 OrchestrationV2ProviderThreadJson,
...@@ -46,6 +47,22 @@ import {...@@ -46,6 +47,22 @@ import {
46} from "./orchestrationV2.ts";47} from "./orchestrationV2.ts";
4748
48const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z");49const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z");
50const encodeDispatchCommandError = Schema.encodeSync(OrchestrationV2DispatchCommandError);
51const decodeDispatchCommandError = Schema.decodeUnknownSync(OrchestrationV2DispatchCommandError);
52
53it("preserves conditional archive rejection across the dispatch error wire schema", () => {
54 const error = new OrchestrationV2DispatchCommandError({
55 commandId: CommandId.make("archive-condition-wire"),
56 commandType: "thread.archive",
57 reason: "archive-condition-changed",
58 message: "The thread changed before archival.",
59 });
60 const encoded = encodeDispatchCommandError(error);
61 const decoded = decodeDispatchCommandError(JSON.parse(JSON.stringify(encoded)));
62
63 expect(decoded.reason).toBe("archive-condition-changed");
64});
65
49function emptyThreadProjection() {66function emptyThreadProjection() {
50 return {67 return {
51 thread: {68 thread: {
packages/contracts/src/orchestrationV2.ts+12
...@@ -2746,6 +2746,7 @@ export const OrchestrationV2Command = Schema.Union([...@@ -2746,6 +2746,7 @@ export const OrchestrationV2Command = Schema.Union([
2746 type: Schema.Literal("thread.archive"),2746 type: Schema.Literal("thread.archive"),
2747 commandId: CommandId,2747 commandId: CommandId,
2748 threadId: ThreadId,2748 threadId: ThreadId,
2749 snapshotSequence: Schema.optional(NonNegativeInt),
2749 }),2750 }),
2750 Schema.Struct({2751 Schema.Struct({
2751 type: Schema.Literal("thread.unarchive"),2752 type: Schema.Literal("thread.unarchive"),
...@@ -2781,6 +2782,7 @@ export const OrchestrationV2Command = Schema.Union([...@@ -2781,6 +2782,7 @@ export const OrchestrationV2Command = Schema.Union([
2781 commandId: CommandId,2782 commandId: CommandId,
2782 threadId: ThreadId,2783 threadId: ThreadId,
2783 reason: Schema.Literal("user"),2784 reason: Schema.Literal("user"),
2785 snapshotSequence: Schema.optional(NonNegativeInt),
2784 }),2786 }),
2785 Schema.Struct({2787 Schema.Struct({
2786 type: Schema.Literal("thread.snooze"),2788 type: Schema.Literal("thread.snooze"),
...@@ -3179,6 +3181,15 @@ export type OrchestrationV2Command = typeof OrchestrationV2Command.Type;...@@ -3179,6 +3181,15 @@ export type OrchestrationV2Command = typeof OrchestrationV2Command.Type;
3179 * send them.3181 * send them.
3180 */3182 */
3181const OrchestrationV2InternalCommand = Schema.Union([3183const OrchestrationV2InternalCommand = Schema.Union([
3184 Schema.Struct({
3185 type: Schema.Literal("thread.ci-failed"),
3186 commandId: CommandId,
3187 threadId: ThreadId,
3188 snapshotSequence: NonNegativeInt,
3189 itemId: TurnItemId,
3190 pullRequestNumber: PositiveInt,
3191 url: TrimmedNonEmptyString,
3192 }),
3182 /**3193 /**
3183 * Records what a pull request watch saw, and wakes the agent in the same transaction when3194 * Records what a pull request watch saw, and wakes the agent in the same transaction when
3184 * `wake` is set. Rejected once the watch started at `startedAt` has ended, and a wake is3195 * `wake` is set. Rejected once the watch started at `startedAt` has ended, and a wake is
...@@ -3535,6 +3546,7 @@ export class OrchestrationV2DispatchCommandError extends Schema.TaggedError<Orch...@@ -3535,6 +3546,7 @@ export class OrchestrationV2DispatchCommandError extends Schema.TaggedError<Orch
3535 {3546 {
3536 commandId: CommandId,3547 commandId: CommandId,
3537 commandType: Schema.String,3548 commandType: Schema.String,
3549 reason: Schema.optional(Schema.Literal("archive-condition-changed")),
3538 message: Schema.String,3550 message: Schema.String,
3539 detail: Schema.optional(Schema.String),3551 detail: Schema.optional(Schema.String),
3540 cause: Schema.optional(Schema.Defect()),3552 cause: Schema.optional(Schema.Defect()),
packages/contracts/src/pullRequest.ts+1
...@@ -153,6 +153,7 @@ export const PullRequestCheck = Schema.Struct({...@@ -153,6 +153,7 @@ export const PullRequestCheck = Schema.Struct({
153 status: PullRequestCheckStatus,153 status: PullRequestCheckStatus,
154 description: Schema.NullOr(Schema.String),154 description: Schema.NullOr(Schema.String),
155 url: Schema.NullOr(Schema.String),155 url: Schema.NullOr(Schema.String),
156 completedAt: Schema.optional(IsoDateTime),
156 /** The base branch requires this check to merge. Absent where the host does not say. */157 /** The base branch requires this check to merge. Absent where the host does not say. */
157 required: Schema.optional(Schema.Boolean),158 required: Schema.optional(Schema.Boolean),
158});159});
packages/contracts/src/rpc.ts+35-3
...@@ -1,5 +1,3 @@...@@ -1,5 +1,3 @@
1import { MergebotQueueInput, MergebotRepoQueue } from "./mergebot.ts";
2import { DevServerTarget, DevServerStatus, DevServerError } from "./devServer.ts";
3import {1import {
4 UiSettingsProfile,2 UiSettingsProfile,
5 UiSettingsPatchInput,3 UiSettingsPatchInput,
...@@ -11,6 +9,15 @@ import {...@@ -11,6 +9,15 @@ import {
11 ScriptProgressEvent,9 ScriptProgressEvent,
12 ScriptProgressStreamError,10 ScriptProgressStreamError,
13} from "./scriptProgress.ts";11} from "./scriptProgress.ts";
12import {
13 ManagedWorkspaceStatus,
14 ManagedWorkspaceCreated,
15 ManagedWorkspaceError,
16 ManagedWorkspaceArchiveInput,
17 ManagedWorkspaceArchived,
18} from "./managedWorkspaces.ts";
19import { MergebotQueueInput, MergebotRepoQueue } from "./mergebot.ts";
20import { DevServerTarget, DevServerStatus, DevServerError } from "./devServer.ts";
14import {21import {
15 OrchestrationV2SearchThreadError,22 OrchestrationV2SearchThreadError,
16 OrchestrationV2SearchThreadInput,23 OrchestrationV2SearchThreadInput,
...@@ -299,7 +306,6 @@ import {...@@ -299,7 +306,6 @@ import {
299 DeviceSession,306 DeviceSession,
300 DeviceShutdownInput,307 DeviceShutdownInput,
301} from "./device.ts";308} from "./device.ts";
302import {} from "./previewAutomation.ts";
303import {309import {
304 ServerConfigStreamEvent,310 ServerConfigStreamEvent,
305 DesktopUpdateCommitInput,311 DesktopUpdateCommitInput,
...@@ -400,6 +406,9 @@ export const WS_METHODS = {...@@ -400,6 +406,9 @@ export const WS_METHODS = {
400406
401 devServerStatus: "devServer.status",407 devServerStatus: "devServer.status",
402 devServerStart: "devServer.start",408 devServerStart: "devServer.start",
409 managedWorkspacesStatus: "managedWorkspaces.status",
410 managedWorkspacesCreate: "managedWorkspaces.create",
411 managedWorkspacesArchiveCompleted: "managedWorkspaces.archiveCompleted",
403412
404 // Shell methods413 // Shell methods
405 shellOpenInEditor: "shell.openInEditor",414 shellOpenInEditor: "shell.openInEditor",
...@@ -1281,6 +1290,26 @@ const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, {...@@ -1281,6 +1290,26 @@ const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, {
1281 error: Schema.Union([ProjectListEntriesError, EnvironmentAuthorizationError]),1290 error: Schema.Union([ProjectListEntriesError, EnvironmentAuthorizationError]),
1282});1291});
12831292
1293const WsManagedWorkspacesArchiveCompletedRpc = Rpc.make(
1294 WS_METHODS.managedWorkspacesArchiveCompleted,
1295 {
1296 payload: ManagedWorkspaceArchiveInput,
1297 success: ManagedWorkspaceArchived,
1298 error: Schema.Union([ManagedWorkspaceError, EnvironmentAuthorizationError]),
1299 },
1300);
1301
1302const WsManagedWorkspacesStatusRpc = Rpc.make(WS_METHODS.managedWorkspacesStatus, {
1303 payload: Schema.Struct({}),
1304 success: ManagedWorkspaceStatus,
1305 error: EnvironmentAuthorizationError,
1306});
1307const WsManagedWorkspacesCreateRpc = Rpc.make(WS_METHODS.managedWorkspacesCreate, {
1308 payload: Schema.Struct({}),
1309 success: ManagedWorkspaceCreated,
1310 error: Schema.Union([ManagedWorkspaceError, EnvironmentAuthorizationError]),
1311});
1312
1284const WsDevServerStatusRpc = Rpc.make(WS_METHODS.devServerStatus, {1313const WsDevServerStatusRpc = Rpc.make(WS_METHODS.devServerStatus, {
1285 payload: DevServerTarget,1314 payload: DevServerTarget,
1286 success: DevServerStatus,1315 success: DevServerStatus,
...@@ -2080,6 +2109,9 @@ export const WsRpcGroup = RpcGroup.make(...@@ -2080,6 +2109,9 @@ export const WsRpcGroup = RpcGroup.make(
2080 WsProjectsReadFileRpc,2109 WsProjectsReadFileRpc,
2081 WsProjectsWatchRpc,2110 WsProjectsWatchRpc,
2082 WsDevServerStatusRpc,2111 WsDevServerStatusRpc,
2112 WsManagedWorkspacesStatusRpc,
2113 WsManagedWorkspacesCreateRpc,
2114 WsManagedWorkspacesArchiveCompletedRpc,
2083 WsDevServerStartRpc,2115 WsDevServerStartRpc,
2084 WsProjectsSearchContentsRpc,2116 WsProjectsSearchContentsRpc,
2085 WsProjectsSearchEntriesRpc,2117 WsProjectsSearchEntriesRpc,
packages/contracts/src/server.ts+1
...@@ -675,6 +675,7 @@ export type ServerDirectEndpoint = typeof ServerDirectEndpoint.Type;...@@ -675,6 +675,7 @@ export type ServerDirectEndpoint = typeof ServerDirectEndpoint.Type;
675675
676export const ServerConfig = Schema.Struct({676export const ServerConfig = Schema.Struct({
677 devServerStatus: Schema.optionalKey(Schema.Boolean),677 devServerStatus: Schema.optionalKey(Schema.Boolean),
678 managedWorkspaces: Schema.optionalKey(Schema.Boolean),
678 mergebot: Schema.optionalKey(MergebotConfiguration),679 mergebot: Schema.optionalKey(MergebotConfiguration),
679 environment: ExecutionEnvironmentDescriptor,680 environment: ExecutionEnvironmentDescriptor,
680 auth: ServerAuthDescriptor,681 auth: ServerAuthDescriptor,
packages/source-control-github/src/server/gitHubPullRequestJson.test.ts+32
...@@ -435,6 +435,38 @@ describe("pull request detail decoding", () => {...@@ -435,6 +435,38 @@ describe("pull request detail decoding", () => {
435 expect(detail.checksState).toBe("pending");435 expect(detail.checksState).toBe("pending");
436 });436 });
437437
438 it("preserves a failed rerun's job URL and completion time", () => {
439 const raw = JSON.parse(detailJson) as Record<string, unknown>;
440 const previous = {
441 __typename: "CheckRun",
442 name: "build",
443 workflowName: "Checks",
444 status: "COMPLETED",
445 conclusion: "FAILURE",
446 startedAt: "2026-08-11T16:00:00Z",
447 completedAt: "2026-08-11T16:01:00Z",
448 detailsUrl: "https://github.com/owner/repository/actions/runs/1/job/2",
449 };
450 const rerun = {
451 ...previous,
452 startedAt: "2026-08-11T17:00:00Z",
453 completedAt: "2026-08-11T17:01:00Z",
454 detailsUrl: "https://github.com/owner/repository/actions/runs/1/job/3",
455 };
456 const detail = expectSuccess(
457 decodePullRequestDetailJson(JSON.stringify({ ...raw, statusCheckRollup: [previous, rerun] })),
458 );
459 expect(detail.checks).toEqual([
460 {
461 name: "build",
462 status: "failure",
463 description: null,
464 url: rerun.detailsUrl,
465 completedAt: rerun.completedAt,
466 },
467 ]);
468 });
469
438 it("keeps a check pending while another run of it on the same commit is still going", () => {470 it("keeps a check pending while another run of it on the same commit is still going", () => {
439 // A workflow on both `push` and `pull_request` runs the same check twice on one commit. The471 // A workflow on both `push` and `pull_request` runs the same check twice on one commit. The
440 // `pull_request` run finished after the `push` run started, and the `push` run is still going.472 // `pull_request` run finished after the `push` run started, and the `push` run is still going.
packages/source-control-github/src/server/gitHubPullRequestJson.ts+2
...@@ -1651,6 +1651,7 @@ function toCheckEntries(...@@ -1651,6 +1651,7 @@ function toCheckEntries(
1651 const name = trimmed(check.name) ?? trimmed(check.context);1651 const name = trimmed(check.name) ?? trimmed(check.context);
1652 if (name === null) return [];1652 if (name === null) return [];
1653 const status = toCheckStatus(check);1653 const status = toCheckStatus(check);
1654 const completedAt = trimmed(check.completedAt);
1654 return [1655 return [
1655 {1656 {
1656 check: {1657 check: {
...@@ -1658,6 +1659,7 @@ function toCheckEntries(...@@ -1658,6 +1659,7 @@ function toCheckEntries(
1658 status,1659 status,
1659 description: trimmed(check.description),1660 description: trimmed(check.description),
1660 url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl),1661 url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl),
1662 ...(completedAt === null || completedAt.startsWith("0001-") ? {} : { completedAt }),
1661 ...(typeof check.isRequired === "boolean" ? { required: check.isRequired } : {}),1663 ...(typeof check.isRequired === "boolean" ? { required: check.isRequired } : {}),
1662 },1664 },
1663 workflowName: trimmed(check.workflowName),1665 workflowName: trimmed(check.workflowName),