From 2534ffb3a27bbc5fe0ad430d855ffa97a8885f91 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 15:04:51 +0000 Subject: [PATCH 01/44] fix: settle and snooze threads while their environment is offline Settling or snoozing a thread from an offline machine failed with " is not connected." The change now applies locally, is saved to disk, and is sent when the environment reconnects with a synchronized shell. The server rejects a replayed settle or snooze if the thread has any event after the client's shell sequence, so changes made on that machine win. Replays keep their command id, so a retry after a lost acknowledgement is deduplicated by the command receipt. Undo before sync cancels the queued change, expired snoozes are dropped, and servers without the guard never receive a replay. Offline settle and snooze stay available only where the last loaded session could change threads. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/connection/platform.ts | 2 + apps/mobile/src/connection/storage.ts | 3 + .../home/useThreadListActions.test.ts | 126 +++--- .../src/features/home/useThreadListActions.ts | 26 +- .../features/threads/thread-list-v2-items.tsx | 75 ++-- .../src/state/thread-triage-outbox-storage.ts | 28 ++ apps/mobile/src/state/threads.ts | 5 + .../src/environment/ServerEnvironment.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 19 + .../src/orchestration-v2/runtimeLayer.test.ts | 92 +++++ .../components/sidebar/SidebarThreadRow.tsx | 29 +- apps/web/src/connection/platform.ts | 8 +- apps/web/src/connection/storage.ts | 3 + .../src/connection/threadTriageOutboxStore.ts | 20 + apps/web/src/hooks/useThreadActions.ts | 4 +- apps/web/src/state/threads.ts | 5 + docs/user/thread-sidebar.md | 5 + .../client-runtime/src/operations/commands.ts | 37 +- packages/client-runtime/src/platform/index.ts | 1 + .../src/platform/threadTriageOutboxStore.ts | 147 +++++++ packages/client-runtime/src/state/session.ts | 19 +- .../src/state/threadCommands.test.ts | 386 +++++++++++++++++- .../src/state/threadCommands.ts | 136 ++++-- .../src/state/threadTriageOutbox.ts | 379 +++++++++++++++++ packages/contracts/src/environment.ts | 2 + packages/contracts/src/orchestrationV2.ts | 4 + 26 files changed, 1413 insertions(+), 149 deletions(-) create mode 100644 apps/mobile/src/state/thread-triage-outbox-storage.ts create mode 100644 apps/web/src/connection/threadTriageOutboxStore.ts create mode 100644 packages/client-runtime/src/platform/threadTriageOutboxStore.ts create mode 100644 packages/client-runtime/src/state/threadTriageOutbox.ts diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 52a6106436389..d2b75a8de54c7 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -27,6 +27,7 @@ import { appAtomRegistry } from "../state/atom-registry"; import { clearThreadOutboxEnvironment } from "../state/thread-outbox-removal"; import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; import { clearThreadComposerErrorsForEnvironment } from "../state/thread-composer-error"; +import { mobileThreadTriageOutboxStore } from "../state/thread-triage-outbox-storage"; import { mobileApplicationActiveWakeup } from "./app-state-wakeups"; import * as ConnectionStorage from "./storage"; @@ -258,6 +259,7 @@ const layerEnvironmentOwnedDataCleanup = Layer.succeed( Effect.promise(() => clearThreadOutboxEnvironment(environmentId)), Effect.promise(() => clearComposerDraftsEnvironment(environmentId)), Effect.sync(() => clearThreadComposerErrorsForEnvironment(environmentId)), + mobileThreadTriageOutboxStore.clearEnvironment(environmentId), ], { concurrency: "unbounded", discard: true }, ).pipe( diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 83805e11942b1..2ab12c5fcca1c 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -7,6 +7,7 @@ import { removeCatalogValue, replaceCatalogValue, Persistence, + ThreadTriageOutboxStore, } from "@t3tools/client-runtime/platform"; import { TokenStore } from "@t3tools/client-runtime/authorization"; import { @@ -21,6 +22,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import { mobileThreadTriageOutboxStore } from "../state/thread-triage-outbox-storage"; import * as CatalogStore from "./catalog-store"; function targetPersistenceError( @@ -155,6 +157,7 @@ export const layer = Layer.effectContext( Context.add(ProfileStore.ConnectionProfileStore, profileStore), Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), + Context.add(ThreadTriageOutboxStore, mobileThreadTriageOutboxStore), ); }), ); diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 9a59367a9543a..788c09970fceb 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -18,6 +18,7 @@ const state = vi.hoisted(() => ({ failedAction: null as string | null, archivedRefreshes: [] as string[], scopes: new Map>(), + offlineTriage: new Set(), shells: [] as EnvironmentThreadShell[], requests: [] as { action: string; @@ -69,28 +70,32 @@ vi.mock("../../state/atom-registry", () => ({ state.dropBusy = value; }, get: (atom: string) => - atom === "thread-drop-busy" - ? state.dropBusy - : atom === "thread-shells" - ? state.shells - : atom === "queued-thread-keys" - ? new Set() - : new Map( - [...state.scopes.keys()].map((environmentId) => [ - environmentId, - { - environment: { - capabilities: { - threadSettlement: true, - threadSnooze: true, - threadPinning: true, - threadPinReorder: true, - threadTitleRegeneration: true, + atom.startsWith("offline-triage:") + ? state.offlineTriage.has(atom.slice("offline-triage:".length)) + : atom.startsWith("pending-sync:") + ? new Set() + : atom === "thread-drop-busy" + ? state.dropBusy + : atom === "thread-shells" + ? state.shells + : atom === "queued-thread-keys" + ? new Set() + : new Map( + [...state.scopes.keys()].map((environmentId) => [ + environmentId, + { + environment: { + capabilities: { + threadSettlement: true, + threadSnooze: true, + threadPinning: true, + threadPinReorder: true, + threadTitleRegeneration: true, + }, + }, }, - }, - }, - ]), - ), + ]), + ), }, })); vi.mock("../../state/use-atom-command", () => ({ @@ -115,36 +120,40 @@ vi.mock("../../state/thread-order", () => ({ })); vi.mock("../../state/threads", () => ({ environmentThreadShells: { threadShellsAtom: "thread-shells" }, - threadEnvironment: Object.fromEntries( - [ - "archive", - "unarchive", - "delete", - "settle", - "unsettle", - "snooze", - "unsnooze", - "pin", - "unpin", - "reorderPin", - "updateMetadata", - ].map((action) => [ - action, - async (request: { - environmentId: string; - input: { threadId: string; orderKey?: string }; - }) => { - state.requests.push({ action, ...request }); - if (!state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope)) { - return AsyncResult.failure(Cause.fail(new Error("Thread operation denied"))); - } - if (action === state.failedAction) - return AsyncResult.failure(Cause.fail(new Error("Server rejected action"))); - state.afterRequest?.(); - return AsyncResult.success(undefined); - }, - ]), - ), + threadEnvironment: { + offlineTriagePermittedAtom: (environmentId: string) => `offline-triage:${environmentId}`, + pendingSyncAtom: (environmentId: string) => `pending-sync:${environmentId}`, + ...Object.fromEntries( + [ + "archive", + "unarchive", + "delete", + "settle", + "unsettle", + "snooze", + "unsnooze", + "pin", + "unpin", + "reorderPin", + "updateMetadata", + ].map((action) => [ + action, + async (request: { + environmentId: string; + input: { threadId: string; orderKey?: string }; + }) => { + state.requests.push({ action, ...request }); + if (!state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope)) { + return AsyncResult.failure(Cause.fail(new Error("Thread operation denied"))); + } + if (action === state.failedAction) + return AsyncResult.failure(Cause.fail(new Error("Server rejected action"))); + state.afterRequest?.(); + return AsyncResult.success(undefined); + }, + ]), + ), + }, })); import { dismissThreadUndoNotice, readThreadUndoNotice } from "./thread-undo-notice"; @@ -180,6 +189,7 @@ beforeEach(() => { state.archivedRefreshes = []; state.pendingOrder = null; state.dropBusy = false; + state.offlineTriage = new Set(); state.scopes = new Map([ [primaryEnvironmentId, new Set([AuthOrchestrationOperateScope])], [otherEnvironmentId, new Set()], @@ -219,6 +229,20 @@ describe("thread list operation permissions", () => { expect(state.requests).toEqual([]); }); + // Offline, the session may not have loaded; only settle, snooze and their undo may queue. + it.each(mutationCases)( + "%s without a loaded session follows the remembered offline triage permission", + async (handler, action) => { + state.scopes.get(primaryEnvironmentId)!.clear(); + state.offlineTriage.add(primaryEnvironmentId); + + await useThreadListActions()[handler](makeThread(), "2099-01-01T00:00:00.000Z"); + + const triage = ["settle", "unsettle", "snooze", "unsnooze"].includes(action); + expect(state.requests.map((request) => request.action)).toEqual(triage ? [action] : []); + }, + ); + it("rechecks permission after a retained delete confirmation", async () => { useThreadListActions().confirmDeleteThread(makeThread()); const confirm = state.dialogs[0]?.onConfirm; diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index ddbbf9ff343c5..cacbe779fcc5a 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -110,8 +110,19 @@ function actionFailureTitle(action: ThreadListAction): string { return "Could not delete thread"; } -function checkThreadOperationPermission(thread: EnvironmentThreadShell, title: string): boolean { +/** Settle, snooze and their undo may queue offline where the last session could change threads. */ +function checkThreadOperationPermission( + thread: EnvironmentThreadShell, + title: string, + triage = false, +): boolean { if (readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return true; + if ( + triage && + appAtomRegistry.get(threadEnvironment.offlineTriagePermittedAtom(thread.environmentId)) + ) { + return true; + } Dialog.alert(title, "This connection cannot change threads."); return false; } @@ -131,7 +142,8 @@ function useThreadActionExecutor( thread: EnvironmentThreadShell, onStarted?: () => void, ) => { - if (!checkThreadOperationPermission(thread, actionFailureTitle(action))) return false; + const triage = action === "settle" || action === "unsettle"; + if (!checkThreadOperationPermission(thread, actionFailureTitle(action), triage)) return false; const key = scopedThreadKey(thread.environmentId, thread.id); if (inFlightThreadKeys.current.has(key)) { return false; @@ -318,13 +330,17 @@ export function useThreadListActions(): { claim = ThreadUndo.begin("settle", key); }); if (claim) { - if (succeeded) offerUndo(thread, "Settled", claim, plan); + // A queued settle never reached the server, so cancelling it restores everything. + const queued = appAtomRegistry + .get(threadEnvironment.pendingSyncAtom(thread.environmentId)) + .has(thread.id); + if (succeeded) offerUndo(thread, "Settled", claim, queued ? [{ action: "unsettle" }] : plan); else claim.finish(); } return succeeded; }; const snoozeThread = async (thread: EnvironmentThreadShell, snoozedUntil: string) => { - if (!checkThreadOperationPermission(thread, "Could not snooze thread")) return false; + if (!checkThreadOperationPermission(thread, "Could not snooze thread", true)) return false; const key = scopedThreadKey(thread.environmentId, thread.id); if (snoozeInFlightThreadKeys.current.has(key)) { return false; @@ -382,7 +398,7 @@ export function useThreadListActions(): { return succeeded; }; const unsnoozeThread = async (thread: EnvironmentThreadShell) => { - if (!checkThreadOperationPermission(thread, "Could not wake thread")) return false; + if (!checkThreadOperationPermission(thread, "Could not wake thread", true)) return false; const key = scopedThreadKey(thread.environmentId, thread.id); if (snoozeInFlightThreadKeys.current.has(key)) { return false; diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 7018f99278986..03a218964a520 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import * as Dialog from "../../components/ui/Dialog"; import type { ThreadRowProviderInstance } from "./thread-provider-instance"; import { @@ -244,6 +245,8 @@ type ThreadListV2ShelfHeaderProps = { readonly pane?: "screen" | "sidebar"; }; +const TRIAGE_MENU_ACTION_IDS = new Set(["settle", "snooze", "unsettle", "unsnooze"]); + const SHELF_LABEL = { working: "Working", snoozed: "Snoozed", settled: "Settled" } as const; function ThreadListV2ShelfHeader( @@ -655,7 +658,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ? { ...workingLabel, label: "Goal" } : workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined); const recede = shouldRecedeThreadRow({ status, selected }); - const timeLabel = props.timeLabel; + const pendingSync = useAtomValue(threadEnvironment.pendingSyncAtom(thread.environmentId)).has( + thread.id, + ); + const timeLabel = pendingSync ? "Waiting to sync" : props.timeLabel; const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const handleAcknowledgeWoke = useCallback(() => { @@ -677,6 +683,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); + // Offline, settle and snooze can still queue where the last session could change threads. + const offlineTriagePermitted = useAtomValue( + threadEnvironment.offlineTriagePermittedAtom(thread.environmentId), + ); + const triageOnly = !canOperateThread && offlineTriagePermitted; const handleRegenerateTitle = useCallback( () => onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, thread], @@ -988,11 +999,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : null, [handleMenuAction, snoozePresetActions, swipeActions.secondary, thread.title], ); - const swipeAccessibilityHint = !canOperateThread - ? "Opens the thread" - : secondaryAction === null - ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.` - : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`; + const swipeAccessibilityHint = + !canOperateThread && !triageOnly + ? "Opens the thread" + : secondaryAction === null + ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.` + : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`; const cardContent = ( <> @@ -1355,7 +1367,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { )} style={{ fontFamily: MONO_FONT }} > - {snoozedRow && props.snoozeWakeLabelText !== undefined + {!pendingSync && snoozedRow && props.snoozeWakeLabelText !== undefined ? props.snoozeWakeLabelText : timeLabel} @@ -1363,8 +1375,29 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); - if (!canOperateThread) + if (!canOperateThread && !(triageOnly && swipeActions.primary !== "archive")) return {rowContent(() => {})}; + const menuActions: MenuAction[] = [ + ...(thread.branch + ? [ + { + id: "new-thread-on-branch", + title: getThreadListV2NewBranchMenuTitle(thread.branch), + image: "square.and.pencil", + }, + ] + : []), + { id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" }, + ...(snoozedRow + ? snoozedMenuActions + : !props.settlementSupported + ? legacyMenuActions + : canUnsettle + ? slimMenuActions + : swipeActions.secondary === "snooze" + ? snoozableCardMenuActions + : cardMenuActions), + ]; return ( @@ -1391,27 +1424,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { > {(close) => ( TRIAGE_MENU_ACTION_IDS.has(action.id ?? "")) + : menuActions + } onPressAction={handleMenuAction} shouldOpenOnLongPress > diff --git a/apps/mobile/src/state/thread-triage-outbox-storage.ts b/apps/mobile/src/state/thread-triage-outbox-storage.ts new file mode 100644 index 0000000000000..3321fe8ab94d7 --- /dev/null +++ b/apps/mobile/src/state/thread-triage-outbox-storage.ts @@ -0,0 +1,28 @@ +import { + makeThreadTriageOutboxStore, + ThreadTriageOutboxStorageError, +} from "@t3tools/client-runtime/platform"; +import * as Effect from "effect/Effect"; + +import { writeFileAtomically } from "../lib/atomic-file"; + +async function getStorageFile() { + const { File, Paths } = await import("expo-file-system"); + return new File(Paths.document, "thread-triage-outbox.json"); +} + +/** Lives outside the client cache so clearing cached data keeps unsynced settles and snoozes. */ +export const mobileThreadTriageOutboxStore = makeThreadTriageOutboxStore({ + read: Effect.tryPromise({ + try: async () => { + const file = await getStorageFile(); + return file.exists ? await file.text() : null; + }, + catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), + }), + write: (value) => + Effect.tryPromise({ + try: async () => writeFileAtomically(await getStorageFile(), value), + catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), + }), +}); diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index dffdb251dda70..fa4ba1398a7ab 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -17,6 +17,7 @@ import { useEffect } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "./atom-registry"; +import { environmentSession } from "./session"; import { environmentShell } from "./shell"; export const threadFamilies = createThreadFamilyAtoms( @@ -30,6 +31,10 @@ export const olderSettledThreads = createOlderSettledThreadAtoms( export const threadEnvironment = createThreadEnvironmentAtoms( connectionAtomRuntime, threadFamilies.snapshotAtom, + { + shellStatusAtom: environmentShell.statusAtom, + operateScopeAtom: environmentSession.operateScopeAtom, + }, ); export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 2bd56e30e9dcb..6ee22e643c58d 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -237,6 +237,7 @@ export const make = Effect.gen(function* () { projectSettingsOverrides: true, projectProviderInstanceOverrides: true, threadSnooze: true, + threadTriageVersionGuard: true, environmentThemes: true, usageLimitSources: true, usagePriceOverrides: true, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 1acc40a6a135f..140f63ce53915 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2987,6 +2987,25 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } } + if ( + (command.type === "thread.settle" || command.type === "thread.snooze") && + command.snapshotSequence !== undefined + ) { + const currentSequence = yield* eventSink + .latestSequence({ threadId: command.threadId }) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + if (currentSequence > command.snapshotSequence) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} changed after the client's snapshot.`, + }); + } + } if (command.type === "thread.settle") { const projection = yield* loadProjectionForCommand( command, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 31e8c04f30f5a..8074ed8f4e209 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -5589,6 +5589,98 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => { assert.isNull(awakened.thread.snoozedAt); }), ); + + // A client replays settle or snooze it took offline; any change after its cached shell wins. + it.effect( + "rejects a replayed settle or snooze once the thread changed after the client's snapshot", + () => + Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projectId = ProjectId.make("runtime-layer-triage-version-project"); + const threadId = ThreadId.make("runtime-layer-triage-version-thread"); + const snoozedUntil = "2099-07-25T09:00:00.000Z"; + yield* projects.create({ + commandId: CommandId.make("runtime-layer-triage-version-project-create"), + projectId, + title: "Triage version", + workspaceRoot: "/tmp/runtime-layer-triage-version-project", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-triage-version-thread-create"), + threadId, + projectId, + title: "Triage version thread", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const versionOf = Effect.fn("versionOf")(function* () { + const shell = yield* orchestrator.getShellSnapshot(); + const thread = shell.threads.find((candidate) => candidate.id === threadId); + assert.isDefined(thread); + return { updatedAt: thread.updatedAt, snapshotSequence: shell.snapshotSequence }; + }); + const rename = (suffix: string) => + orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`runtime-layer-triage-version-rename-${suffix}`), + threadId, + title: `Renamed ${suffix}`, + }); + + // Same millisecond: updatedAt cannot tell the snapshots apart, the sequence can. + const cached = yield* versionOf(); + yield* rename("same-ms"); + assert.deepEqual((yield* versionOf()).updatedAt, cached.updatedAt); + const staleSettle = yield* orchestrator + .dispatch({ + type: "thread.settle", + commandId: CommandId.make("runtime-layer-triage-version-settle-stale"), + threadId, + snapshotSequence: cached.snapshotSequence, + }) + .pipe(Effect.flip); + assert.instanceOf(staleSettle, Orchestrator.OrchestratorDispatchError); + + const beforeLaterChange = yield* versionOf(); + yield* TestClock.adjust("1 second"); + yield* rename("later"); + const staleSnooze = yield* orchestrator + .dispatch({ + type: "thread.snooze", + commandId: CommandId.make("runtime-layer-triage-version-snooze-stale"), + threadId, + snoozedUntil, + snapshotSequence: beforeLaterChange.snapshotSequence, + }) + .pipe(Effect.flip); + assert.instanceOf(staleSnooze, Orchestrator.OrchestratorDispatchError); + const untouched = (yield* orchestrator.getThreadProjection(threadId)).thread; + assert.isNull(untouched.settledOverride); + assert.isNull(untouched.snoozedUntil ?? null); + + const current = yield* versionOf(); + const settle = { + type: "thread.settle" as const, + commandId: CommandId.make("runtime-layer-triage-version-settle"), + threadId, + snapshotSequence: current.snapshotSequence, + }; + const accepted = yield* orchestrator.dispatch(settle); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).thread.settledOverride, + "settled", + ); + // A retry after a lost acknowledgement replays the receipt instead of failing the guard. + assert.equal((yield* orchestrator.dispatch(settle)).sequence, accepted.sequence); + }), + ); }); it.layer(layerSharedApplicationDataPlaneTest)("visited projection", (it) => { diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 0227096adef22..8ccab7f84697b 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { ThreadHoverCard, ThreadHoverCardPopup } from "../ThreadHoverCard"; import { ThreadTerminalStatusButton } from "../ThreadStatusIndicators"; import { discardComposerDraft } from "../../lib/discardComposerDraft"; @@ -66,6 +67,7 @@ import { useNowMinute } from "../../hooks/useNowMinute"; import { useNowSecond } from "../../hooks/useNowSecond"; import { vcsEnvironment } from "../../state/vcs"; import { useEnvironmentQuery } from "../../state/query"; +import { threadEnvironment } from "../../state/threads"; import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { formatRelativeTimeLabel, parseTimestampDate } from "../../timestampFormat"; import type { SidebarThreadSummary } from "../../types"; @@ -133,8 +135,16 @@ function compactSidebarTimeLabel(label: string): string { // Subscribes to the minute tick itself, so only the label re-renders each // minute. The minute is passed as the clock so it is the formatter's input. -function RelativeTimeLabel(props: { timestamp: string | null }) { +function RelativeTimeLabel(props: { timestamp: string | null; pendingSync?: boolean }) { const nowMinute = useNowMinute(); + if (props.pendingSync) { + return ( + + }>Pending + Waiting to sync with its environment + + ); + } return props.timestamp === null ? null : compactSidebarTimeLabel( @@ -586,6 +596,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); + // Offline, snooze can still queue where the last session could change threads. + const offlineTriagePermitted = useAtomValue( + threadEnvironment.offlineTriagePermittedAtom(thread.environmentId), + ); + const pendingSync = useAtomValue(threadEnvironment.pendingSyncAtom(thread.environmentId)).has( + thread.id, + ); useEffect(() => { if (!canOperateThread && isRenaming) onCancelRename(); }, [canOperateThread, isRenaming, onCancelRename]); @@ -876,7 +893,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP return () => window.clearTimeout(id); }, [snoozeGateExpiryMs, snoozeGateNow, thread]); const showSnoozeButton = - canOperateThread && props.snoozeSupported && canSnooze(thread, { now: snoozeGateNow }); + (canOperateThread || offlineTriagePermitted) && + props.snoozeSupported && + canSnooze(thread, { now: snoozeGateNow }); // Closing here, not just masking, keeps the menu from reopening when the button returns. if (snoozeMenuOpenRaw && !showSnoozeButton) setSnoozeMenuOpen(false); const snoozeMenuOpen = snoozeMenuOpenRaw && showSnoozeButton; @@ -1168,6 +1187,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : ( ) ) : ( - + )} {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? ( diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 0fe0129f11a1c..241d019831fcc 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -61,6 +61,7 @@ import { type DesktopSecondaryBootstrapsRead, } from "./desktopLocal"; import * as ConnectionStorage from "./storage"; +import { browserThreadTriageOutboxStore } from "./threadTriageOutboxStore"; import { clientPresentationMetadata } from "./clientMetadata"; let nextObservedRpcRequestId = 0; @@ -794,7 +795,12 @@ const layerEnvironmentOwnedDataCleanup = Layer.succeed( clear: (environmentId) => Effect.sync(() => { clearComposerDraftsEnvironment(environmentId); - }), + }).pipe( + Effect.andThen(browserThreadTriageOutboxStore.clearEnvironment(environmentId)), + Effect.catch((cause) => + Effect.logWarning("Could not clear offline thread changes.", { environmentId, cause }), + ), + ), }), ); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 621a8020c5a28..35cd41f59211d 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -15,6 +15,7 @@ import { setRoutesInCatalog, replaceCatalogValue, Persistence, + ThreadTriageOutboxStore, makeStoredShellSnapshotEncoder, } from "@t3tools/client-runtime/platform"; import { TokenStore } from "@t3tools/client-runtime/authorization"; @@ -39,6 +40,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { projectFaviconCache } from "../assets/projectFaviconCache"; +import { browserThreadTriageOutboxStore } from "./threadTriageOutboxStore"; const DATABASE_NAME = "t3code:connection-runtime"; const DATABASE_VERSION = 4; @@ -931,6 +933,7 @@ export const layer = Layer.effectContext( Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), Context.add(Persistence.EnvironmentCacheStore, cacheStore), + Context.add(ThreadTriageOutboxStore, browserThreadTriageOutboxStore), ); }), ); diff --git a/apps/web/src/connection/threadTriageOutboxStore.ts b/apps/web/src/connection/threadTriageOutboxStore.ts new file mode 100644 index 0000000000000..c645477bc8c5c --- /dev/null +++ b/apps/web/src/connection/threadTriageOutboxStore.ts @@ -0,0 +1,20 @@ +import { + makeThreadTriageOutboxStore, + ThreadTriageOutboxStorageError, +} from "@t3tools/client-runtime/platform"; +import * as Effect from "effect/Effect"; + +const STORAGE_KEY = "t3code:thread-triage-outbox"; + +/** Kept out of the environment cache so clearing cached data keeps unsynced settles and snoozes. */ +export const browserThreadTriageOutboxStore = makeThreadTriageOutboxStore({ + read: Effect.try({ + try: () => window.localStorage.getItem(STORAGE_KEY), + catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), + }), + write: (value) => + Effect.try({ + try: () => window.localStorage.setItem(STORAGE_KEY, value), + catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), + }), +}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 00138102ee751..569defe50f89e 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -746,7 +746,9 @@ export function useThreadActions() { showThreadUndoNotice({ action: "Settled", claim: action, - undo: () => runUndoPlan(target, undoPlan), + // A queued settle never reached the server, so cancelling it restores everything. + undo: () => + runUndoPlan(target, result.value.pendingSync ? [{ action: "unsettle" }] : undoPlan), failureTitle: "Failed to undo settle", }); return result; diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index 9d1a154255e64..abd42ab36c95f 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -19,6 +19,7 @@ import { useEffect } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentSession } from "./session"; import { environmentShell } from "./shell"; export const threadFamilies = createThreadFamilyAtoms( @@ -32,6 +33,10 @@ export const olderSettledThreads = createOlderSettledThreadAtoms( export const threadEnvironment = createThreadEnvironmentAtoms( connectionAtomRuntime, threadFamilies.snapshotAtom, + { + shellStatusAtom: environmentShell.statusAtom, + operateScopeAtom: environmentSession.operateScopeAtom, + }, ); const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 560f7dd758442..4e2d10786a3a6 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -140,6 +140,11 @@ sending an answer or restarting the agent. Settling also closes the thread's terminals that wait at an idle prompt, and keeps their output. A terminal that runs a command, such as a dev server, stays open. +You can settle or snooze a thread while its environment is offline. The thread +shows **Waiting to sync** on mobile or **Pending** on web and desktop, and the change +is sent when the environment reconnects. If the thread changed there in the meantime, +that environment's state wins and your change is dropped. + To reclaim disk space from settled work, turn on **Run in the thread's worktree when the thread settles** for one of the project's actions, or set `"runOnSettle": true` on a `t3.json` script, for example `cargo clean`. It runs each time a thread in its own diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 1f96499470712..e63610f888c6b 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -33,6 +33,7 @@ import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2Pend import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { getInitialServerConfig, request } from "../rpc/client.ts"; @@ -86,7 +87,10 @@ export interface ThreadCommandInput extends CommandMetadata { export type DeleteThreadInput = ThreadCommandInput; export type ArchiveThreadInput = ThreadCommandInput; export type UnarchiveThreadInput = ThreadCommandInput; -export type SettleThreadInput = ThreadCommandInput; +export interface SettleThreadInput extends ThreadCommandInput { + /** Replays an offline settle; the server rejects it if the thread changed after this. */ + readonly snapshotSequence?: number; +} export interface UnsettleThreadInput extends ThreadCommandInput { readonly reason: "user"; @@ -107,6 +111,8 @@ export interface ReorderActiveThreadInput extends ThreadCommandInput { export interface SnoozeThreadInput extends ThreadCommandInput { readonly snoozedUntil: string; + /** Replays an offline snooze; the server rejects it if the thread changed after this. */ + readonly snapshotSequence?: number; } export interface UnsnoozeThreadInput extends ThreadCommandInput { @@ -282,6 +288,22 @@ const supportsServerResolvedCommandContext = Effect.fn( return config.environment.capabilities.serverResolvedCommandContext === true; }); +export class ThreadVersionGuardUnsupportedError extends Schema.TaggedError()( + "ThreadVersionGuardUnsupportedError", + { message: Schema.String }, +) {} + +// Older servers drop snapshotSequence, so a replay there could overwrite newer changes. +const requireThreadVersionGuard = Effect.fn("EnvironmentCommands.requireThreadVersionGuard")( + function* () { + const config = yield* getInitialServerConfig(); + if (config.environment.capabilities.threadTriageVersionGuard === true) return; + return yield* new ThreadVersionGuardUnsupportedError({ + message: "This environment's server is too old to sync offline changes. Update the server.", + }); + }, +); + const persistAttachments = Effect.fn("EnvironmentCommands.persistAttachments")(function* ( threadId: ThreadId, messageId: MessageId, @@ -488,7 +510,16 @@ export const unarchiveThread = Effect.fn("EnvironmentCommands.unarchiveThread")( export const settleThread = Effect.fn("EnvironmentCommands.settleThread")(function* ( input: SettleThreadInput, ) { - return yield* simpleThreadCommand("thread.settle", input); + if (input.snapshotSequence === undefined) { + return yield* simpleThreadCommand("thread.settle", input); + } + yield* requireThreadVersionGuard(); + return yield* dispatch({ + type: "thread.settle", + commandId: yield* allocateCommandId(input), + threadId: input.threadId, + snapshotSequence: input.snapshotSequence, + }); }); export const pinThread = Effect.fn("EnvironmentCommands.pinThread")(function* ( @@ -560,11 +591,13 @@ export const unsettleThread = Effect.fn("EnvironmentCommands.unsettleThread")(fu export const snoozeThread = Effect.fn("EnvironmentCommands.snoozeThread")(function* ( input: SnoozeThreadInput, ) { + if (input.snapshotSequence !== undefined) yield* requireThreadVersionGuard(); return yield* dispatch({ type: "thread.snooze", commandId: yield* allocateCommandId(input), threadId: input.threadId, snoozedUntil: input.snoozedUntil, + ...(input.snapshotSequence === undefined ? {} : { snapshotSequence: input.snapshotSequence }), }); }); diff --git a/packages/client-runtime/src/platform/index.ts b/packages/client-runtime/src/platform/index.ts index 18efb9be18bc0..3a13543498ce6 100644 --- a/packages/client-runtime/src/platform/index.ts +++ b/packages/client-runtime/src/platform/index.ts @@ -6,3 +6,4 @@ export * from "./orchestrationCache.ts"; export * from "./shellCacheEncoding.ts"; export * as PlatformConnectionSource from "./source.ts"; export * from "./storageDocument.ts"; +export * from "./threadTriageOutboxStore.ts"; diff --git a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts new file mode 100644 index 0000000000000..6431e8f3c78fb --- /dev/null +++ b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts @@ -0,0 +1,147 @@ +import { CommandId, EnvironmentId, NonNegativeInt, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +export const PendingThreadTriage = Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, + commandId: CommandId, + action: Schema.Union([ + Schema.Struct({ type: Schema.Literal("settle") }), + Schema.Struct({ type: Schema.Literal("snooze"), snoozedUntil: Schema.String }), + ]), + queuedAt: Schema.DateTimeUtcFromString, + /** Shell sequence the action was taken against; the server rejects it after newer changes. */ + snapshotSequence: NonNegativeInt, +}); +export type PendingThreadTriage = typeof PendingThreadTriage.Type; + +const ThreadTriageOutboxDocument = Schema.Struct({ + entries: Schema.Array(PendingThreadTriage), + /** Environments whose last loaded session could change threads. */ + operableEnvironmentIds: Schema.Array(EnvironmentId), +}); +export type ThreadTriageOutboxDocument = typeof ThreadTriageOutboxDocument.Type; + +const EMPTY_DOCUMENT: ThreadTriageOutboxDocument = { entries: [], operableEnvironmentIds: [] }; +const StoredDocument = Schema.fromJsonString(ThreadTriageOutboxDocument); +const decodeStored = Schema.decodeUnknownEffect(StoredDocument); +const encodeStored = Schema.encodeEffect(StoredDocument); + +export class ThreadTriageOutboxStorageError extends Schema.TaggedError()( + "ThreadTriageOutboxStorageError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not save the change for later sync."; + } +} + +export type ThreadTriageKey = Pick; + +export const sameThreadTriage = (a: ThreadTriageKey, b: ThreadTriageKey) => + a.environmentId === b.environmentId && a.threadId === b.threadId; + +/** + * Settle and snooze taken while an environment is offline, kept apart from the environment + * cache so clearing the cache does not drop them. Removing the environment does. + */ +export interface ThreadTriageOutboxStoreShape { + readonly load: Effect.Effect; + /** Replaces any entry for the same thread. */ + readonly put: (entry: PendingThreadTriage) => Effect.Effect; + /** Only removes the entry if it still has this command id. */ + readonly remove: ( + entry: ThreadTriageKey & Pick, + ) => Effect.Effect; + readonly setOperable: ( + environmentId: EnvironmentId, + operable: boolean, + ) => Effect.Effect; + readonly clearEnvironment: ( + environmentId: EnvironmentId, + ) => Effect.Effect; +} + +export class ThreadTriageOutboxStore extends Context.Reference( + "@t3tools/client-runtime/state/ThreadTriageOutboxStore", + { + defaultValue: () => { + let value: string | null = null; + return makeThreadTriageOutboxStore({ + read: Effect.sync(() => value), + write: (next) => + Effect.sync(() => { + value = next; + }), + }); + }, + }, +) {} + +export function makeThreadTriageOutboxStore(storage: { + readonly read: Effect.Effect; + readonly write: (value: string) => Effect.Effect; +}): ThreadTriageOutboxStoreShape { + const lock = Semaphore.makeUnsafe(1); + const load = storage.read.pipe( + Effect.flatMap((raw) => + raw === null + ? Effect.succeed(EMPTY_DOCUMENT) + : decodeStored(raw).pipe( + Effect.catch((cause) => + Effect.logWarning("Discarding unreadable offline thread changes.", { + cause: String(cause), + }).pipe(Effect.as(EMPTY_DOCUMENT)), + ), + ), + ), + ); + const update = ( + transform: (document: ThreadTriageOutboxDocument) => ThreadTriageOutboxDocument, + ) => + lock.withPermits(1)( + load.pipe( + Effect.flatMap((document) => + encodeStored(transform(document)).pipe( + Effect.mapError((cause) => new ThreadTriageOutboxStorageError({ cause })), + ), + ), + Effect.flatMap(storage.write), + ), + ); + const updateEntries = ( + transform: (entries: ReadonlyArray) => ReadonlyArray, + ) => update((document) => ({ ...document, entries: transform(document.entries) })); + return { + load: lock.withPermits(1)(load), + put: (entry) => + updateEntries((entries) => [ + ...entries.filter((current) => !sameThreadTriage(current, entry)), + entry, + ]), + remove: (entry) => + updateEntries((entries) => + entries.filter( + (current) => !sameThreadTriage(current, entry) || current.commandId !== entry.commandId, + ), + ), + setOperable: (environmentId, operable) => + update((document) => ({ + ...document, + operableEnvironmentIds: [ + ...document.operableEnvironmentIds.filter((id) => id !== environmentId), + ...(operable ? [environmentId] : []), + ], + })), + clearEnvironment: (environmentId) => + update((document) => ({ + entries: document.entries.filter((entry) => entry.environmentId !== environmentId), + operableEnvironmentIds: document.operableEnvironmentIds.filter( + (id) => id !== environmentId, + ), + })), + }; +} diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 2a62496b343d9..273dc9a2b6a5e 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -1,4 +1,10 @@ -import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + type AuthSessionState, + type EnvironmentId, + type ServerConfig, + sessionGrantsScope, +} from "@t3tools/contracts"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -155,6 +161,16 @@ function makeEnvironmentSessionAtoms( ).pipe(Atom.withLabel(`environment-session-state-value:${environmentId}`)), ); + /** Null until a session loads, so offline callers can tell unknown from denied. */ + const operateScopeAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): boolean | null => { + const result = get(sessionStateAtom(environmentId)); + return AsyncResult.isSuccess(result) + ? sessionGrantsScope(result.value, AuthOrchestrationOperateScope) + : null; + }).pipe(Atom.withLabel(`environment-session-operate-scope:${environmentId}`)), + ); + return { initialConfigAtom, initialConfigValueAtom, @@ -162,6 +178,7 @@ function makeEnvironmentSessionAtoms( preparedConnectionValueAtom, sessionStateAtom, sessionStateValueAtom, + operateScopeAtom, }; } diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index d61213c243ea8..eaa95d4431145 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -15,6 +15,7 @@ import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Cause from "effect/Cause"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -27,7 +28,13 @@ import { Atom, AtomRegistry } from "effect/reactivity"; import * as EnvironmentRegistry from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import type { RpcSession } from "../rpc/session.ts"; +import type { EnvironmentShellStatus } from "./shell.ts"; import { createThreadEnvironmentAtoms } from "./threadCommands.ts"; +import { + makeThreadTriageOutboxStore, + ThreadTriageOutboxStorageError, + ThreadTriageOutboxStore, +} from "../platform/threadTriageOutboxStore.ts"; import * as ThreadHistoryController from "./threadHistoryController.ts"; const ENVIRONMENT_ID = EnvironmentId.make("remote"); @@ -87,27 +94,41 @@ const SNAPSHOT: OrchestrationV2ShellSnapshot = { const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (options?: { readonly reconcilePendingWork?: (threadId: ThreadId) => Effect.Effect; + readonly connected?: boolean; + readonly storage?: { + value: string | null; + failWrites?: boolean; + writes?: Queue.Queue; + }; + readonly versionGuard?: boolean; + readonly operateScope?: boolean | null; }) { const requests = yield* Queue.unbounded<{ command: OrchestrationV2Command; reply: Deferred.Deferred<{ sequence: number }, Error>; }>(); const reconciled = yield* Queue.unbounded(); + const liveSession = { + initialConfig: Effect.succeed({ + environment: { capabilities: { threadTriageVersionGuard: options?.versionGuard ?? true } }, + }), + client: { + [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) => + Effect.gen(function* () { + const reply = yield* Deferred.make<{ sequence: number }, Error>(); + yield* Queue.offer(requests, { command, reply }); + return yield* Deferred.await(reply); + }), + }, + } as unknown as RpcSession; + const session = yield* SubscriptionRef.make( + options?.connected === false ? Option.none() : Option.some(liveSession), + ); const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ - target: { environmentId: ENVIRONMENT_ID }, - session: yield* SubscriptionRef.make( - Option.some({ - client: { - [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) => - Effect.gen(function* () { - const reply = yield* Deferred.make<{ sequence: number }, Error>(); - yield* Queue.offer(requests, { command, reply }); - return yield* Deferred.await(reply); - }), - }, - } as unknown as RpcSession), - ), + target: { environmentId: ENVIRONMENT_ID, label: "Mac" }, + session, } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const storage = options?.storage ?? { value: null }; const runtime = Atom.runtime( Layer.mergeAll( Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, { @@ -121,6 +142,19 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio digest: (_algorithm, data) => Effect.succeed(data), }), ), + Layer.succeed( + ThreadTriageOutboxStore, + makeThreadTriageOutboxStore({ + read: Effect.sync(() => storage.value), + write: (value) => + storage.failWrites + ? Effect.fail(new ThreadTriageOutboxStorageError({ cause: "disk full" })) + : Effect.sync(() => { + storage.value = value; + if (storage.writes) Queue.offerUnsafe(storage.writes, value); + }), + }), + ), Layer.succeed( ThreadHistoryController.ThreadHistoryController, ThreadHistoryController.ThreadHistoryController.of({ @@ -134,12 +168,40 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio ), ); const snapshotAtom = Atom.family((_environmentId: EnvironmentId) => Atom.make(SNAPSHOT)); - const commands = createThreadEnvironmentAtoms(runtime, snapshotAtom); + const statusAtom = Atom.family((_environmentId: EnvironmentId) => + Atom.make(options?.connected === false ? "cached" : "live"), + ); + const operateScopeAtom = Atom.family((_environmentId: EnvironmentId) => + Atom.make(options?.operateScope === undefined ? true : options.operateScope), + ); + const commands = createThreadEnvironmentAtoms(runtime, snapshotAtom, { + shellStatusAtom: statusAtom, + operateScopeAtom, + }); const registry = AtomRegistry.make(); yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); const visibleAtom = commands.snapshotAtom(ENVIRONMENT_ID); registry.mount(visibleAtom); - return { registry, commands, snapshotAtom, visibleAtom, requests, reconciled }; + const reconnect = (status: EnvironmentShellStatus = "live") => + SubscriptionRef.set(session, Option.some(liveSession)).pipe( + Effect.andThen(Effect.sync(() => registry.set(statusAtom(ENVIRONMENT_ID), status))), + ); + const disconnect = SubscriptionRef.set(session, Option.none()).pipe( + Effect.andThen(Effect.sync(() => registry.set(statusAtom(ENVIRONMENT_ID), "cached"))), + ); + return { + registry, + commands, + snapshotAtom, + statusAtom, + operateScopeAtom, + visibleAtom, + requests, + reconciled, + storage, + reconnect, + disconnect, + }; }); describe("interrupt turn", () => { @@ -251,7 +313,8 @@ describe("remote thread lifecycle commands", () => { const request = yield* Queue.take(h.requests); expect(h.registry.get(source)).toBe(initial); yield* Deferred.fail(request.reply, new Error("Remote rejected the action")); - expect((yield* Effect.promise(() => result))._tag).toBe("Failure"); + const settled: Promise<{ readonly _tag: string }> = result; + expect((yield* Effect.promise(() => settled))._tag).toBe("Failure"); expect(h.registry.get(h.visibleAtom)).toBe(initial); }), ); @@ -452,3 +515,294 @@ describe("remote thread lifecycle commands", () => { }), ); }); + +const waitFor = ( + registry: AtomRegistry.AtomRegistry, + atom: Atom.Atom, + done: (value: A) => boolean, +) => + Effect.callback((resume) => { + if (done(registry.get(atom))) return resume(Effect.void); + const unsubscribe = registry.subscribe(atom, (value) => { + if (!done(value)) return; + unsubscribe(); + resume(Effect.void); + }); + return Effect.sync(unsubscribe); + }); + +describe("settle and snooze while the environment is offline", () => { + const settle = { environmentId: ENVIRONMENT_ID, input: { threadId: THREAD_ID } }; + const snooze = { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, snoozedUntil: "2099-01-01T00:00:00.000Z" }, + }; + const stored = (storage: { value: string | null }) => + storage.value === null + ? [] + : (JSON.parse(storage.value) as { entries: Array> }).entries; + + it.effect("applies a settle locally and replays it with the cached version on reconnect", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false }); + const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); + h.registry.mount(pending); + const result = yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + expect(result._tag).toBe("Success"); + expect(h.registry.get(h.visibleAtom)?.threads[0]?.settledOverride).toBe("settled"); + expect(h.registry.get(pending).has(THREAD_ID)).toBe(true); + const [entry] = stored(h.storage); + expect(entry).toMatchObject({ + environmentId: ENVIRONMENT_ID, + threadId: THREAD_ID, + action: { type: "settle" }, + snapshotSequence: 1, + }); + + yield* h.reconnect("synchronizing"); + expect(yield* Queue.size(h.requests)).toBe(0); + h.registry.set(h.statusAtom(ENVIRONMENT_ID), "live"); + const request = yield* Queue.take(h.requests); + expect(request.command).toMatchObject({ + type: "thread.settle", + commandId: entry!.commandId, + threadId: THREAD_ID, + snapshotSequence: 1, + }); + yield* Deferred.succeed(request.reply, { sequence: 2 }); + yield* waitFor(h.registry, pending, (threads) => threads.size === 0); + expect(stored(h.storage)).toEqual([]); + // Stays settled until the shell catches up with the accepted command. + expect(h.registry.get(h.visibleAtom)?.threads[0]?.settledOverride).toBe("settled"); + const confirmed = { + ...SNAPSHOT, + snapshotSequence: 2, + threads: [{ ...SNAPSHOT.threads[0]!, settledOverride: "settled" as const, settledAt: NOW }], + }; + h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), confirmed); + expect(h.registry.get(h.visibleAtom)).toBe(confirmed); + }), + ); + + it.effect("restores a queued snooze after a restart and replays it once", () => + Effect.gen(function* () { + const storage = { value: null as string | null }; + const first = yield* makeHarness({ connected: false, storage }); + expect( + (yield* Effect.promise(() => first.commands.snooze.run(first.registry, snooze)))._tag, + ).toBe("Success"); + const [entry] = stored(storage); + + const restarted = yield* makeHarness({ connected: false, storage }); + const pending = restarted.commands.pendingSyncAtom(ENVIRONMENT_ID); + restarted.registry.mount(pending); + yield* waitFor(restarted.registry, pending, (threads) => threads.has(THREAD_ID)); + expect(restarted.registry.get(restarted.visibleAtom)?.threads[0]?.snoozedUntil).toEqual( + FUTURE, + ); + + yield* restarted.reconnect(); + const request = yield* Queue.take(restarted.requests); + expect(request.command).toMatchObject({ + type: "thread.snooze", + commandId: entry!.commandId, + snoozedUntil: "2099-01-01T00:00:00.000Z", + }); + yield* Deferred.succeed(request.reply, { sequence: 2 }); + yield* waitFor(restarted.registry, pending, (threads) => threads.size === 0); + expect(yield* Queue.size(restarted.requests)).toBe(0); + }), + ); + + it.effect("shows the server state when the server rejects the replay as changed", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false }); + const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); + h.registry.mount(pending); + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + const changed = { + ...SNAPSHOT, + snapshotSequence: 5, + threads: [ + { ...SNAPSHOT.threads[0]!, title: "Changed on the Mac", status: "running" as const }, + ], + }; + h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), changed); + yield* h.reconnect(); + const request = yield* Queue.take(h.requests); + yield* Deferred.fail(request.reply, new Error("Thread changed after the client's version.")); + yield* waitFor(h.registry, pending, (threads) => threads.size === 0); + expect(stored(h.storage)).toEqual([]); + expect(h.registry.get(h.visibleAtom)).toBe(changed); + }), + ); + + it.effect("keeps the queued action when the connection drops again and retries it", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false }); + const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); + h.registry.mount(pending); + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + const [entry] = stored(h.storage); + // The shell reports live from a stale stream while the session is already gone. + h.registry.set(h.statusAtom(ENVIRONMENT_ID), "live"); + yield* h.disconnect; + expect(h.registry.get(pending).has(THREAD_ID)).toBe(true); + yield* h.reconnect(); + const request = yield* Queue.take(h.requests); + expect(request.command.commandId).toBe(entry!.commandId); + yield* Deferred.succeed(request.reply, { sequence: 2 }); + yield* waitFor(h.registry, pending, (threads) => threads.size === 0); + }), + ); + + it.effect.each([ + ["settle", "unsettle"], + ["snooze", "unsnooze"], + ] as const)("undoing a queued %s cancels it without contacting the server", ([action, undo]) => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false }); + const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); + h.registry.mount(pending); + yield* Effect.promise(() => h.commands[action].run(h.registry, snooze)); + const result = yield* Effect.promise(() => + h.commands[undo].run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, reason: "user" }, + }), + ); + expect(result._tag).toBe("Success"); + expect(h.registry.get(pending).size).toBe(0); + expect(stored(h.storage)).toEqual([]); + expect(h.registry.get(h.visibleAtom)).toBe(SNAPSHOT); + yield* h.reconnect(); + expect(yield* Queue.size(h.requests)).toBe(0); + }), + ); + + it.effect("drops a queued snooze that expired before the reconnect", () => + Effect.gen(function* () { + const storage = { value: null as string | null }; + const h = yield* makeHarness({ connected: false, storage }); + const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); + h.registry.mount(pending); + yield* Effect.promise(() => h.commands.snooze.run(h.registry, snooze)); + storage.value = storage.value!.replace( + "2099-01-01T00:00:00.000Z", + "2000-01-01T00:00:00.000Z", + ); + const restarted = yield* makeHarness({ connected: false, storage }); + const restartedPending = restarted.commands.pendingSyncAtom(ENVIRONMENT_ID); + restarted.registry.mount(restartedPending); + yield* waitFor(restarted.registry, restartedPending, (threads) => threads.size === 1); + yield* restarted.reconnect(); + yield* waitFor(restarted.registry, restartedPending, (threads) => threads.size === 0); + expect(yield* Queue.size(restarted.requests)).toBe(0); + expect(stored(storage)).toEqual([]); + }), + ); + + it.effect("keeps the not-connected failure when the change cannot be saved", () => + Effect.gen(function* () { + const h = yield* makeHarness({ + connected: false, + storage: { value: null, failWrites: true }, + }); + const result = yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + expect(result._tag).toBe("Failure"); + expect(String(result._tag === "Failure" ? Cause.squash(result.cause) : "")).toContain( + "Mac is not connected.", + ); + expect(h.registry.get(h.visibleAtom)).toBe(SNAPSHOT); + }), + ); + + it.effect("drops a queued change when the server cannot check versions", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false, versionGuard: false }); + const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); + h.registry.mount(pending); + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + yield* h.reconnect(); + yield* waitFor(h.registry, pending, (threads) => threads.size === 0); + expect(yield* Queue.size(h.requests)).toBe(0); + expect(h.registry.get(h.visibleAtom)).toBe(SNAPSHOT); + }), + ); + + it.effect("keeps queued changes to their own environment", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false }); + const other = EnvironmentId.make("other-remote"); + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + expect(h.registry.get(h.commands.snapshotAtom(other))).toBe(SNAPSHOT); + expect(h.registry.get(h.commands.pendingSyncAtom(other)).size).toBe(0); + }), + ); + + it.effect("remembers which environments may triage offline across a cold restart", () => + Effect.gen(function* () { + const writes = yield* Queue.unbounded(); + const storage = { value: null as string | null, writes }; + const online = yield* makeHarness({ storage, operateScope: true }); + online.registry.mount(online.commands.offlineTriagePermittedAtom(ENVIRONMENT_ID)); + yield* Queue.take(writes); + + const coldOffline = yield* makeHarness({ connected: false, storage, operateScope: null }); + const permitted = coldOffline.commands.offlineTriagePermittedAtom(ENVIRONMENT_ID); + coldOffline.registry.mount(permitted); + yield* waitFor(coldOffline.registry, permitted, (value) => value); + + // A session that loads without the scope wins and is remembered for the next restart. + coldOffline.registry.set(coldOffline.operateScopeAtom(ENVIRONMENT_ID), false); + expect(coldOffline.registry.get(permitted)).toBe(false); + yield* Queue.take(writes); + const restarted = yield* makeHarness({ connected: false, storage, operateScope: null }); + const restartedPermitted = restarted.commands.offlineTriagePermittedAtom(ENVIRONMENT_ID); + restarted.registry.mount(restartedPermitted); + yield* Effect.promise(() => + restarted.commands.settle.run(restarted.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID }, + }), + ); + expect(restarted.registry.get(restartedPermitted)).toBe(false); + }), + ); + + it.effect("does not allow offline triage for an environment never seen with the scope", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false, operateScope: null }); + const permitted = h.commands.offlineTriagePermittedAtom(ENVIRONMENT_ID); + h.registry.mount(permitted); + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + expect(h.registry.get(permitted)).toBe(false); + }), + ); + + it.effect("keeps a queued settle when Undo cannot remove it from storage", () => + Effect.gen(function* () { + const storage = { value: null as string | null, failWrites: false }; + const h = yield* makeHarness({ connected: false, storage }); + const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); + h.registry.mount(pending); + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + storage.failWrites = true; + const undo = yield* Effect.promise(() => + h.commands.unsettle.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, reason: "user" }, + }), + ); + expect(undo._tag).toBe("Failure"); + expect(h.registry.get(pending).has(THREAD_ID)).toBe(true); + expect(h.registry.get(h.visibleAtom)?.threads[0]?.settledOverride).toBe("settled"); + + storage.failWrites = false; + const restarted = yield* makeHarness({ connected: false, storage }); + const restartedPending = restarted.commands.pendingSyncAtom(ENVIRONMENT_ID); + restarted.registry.mount(restartedPending); + yield* waitFor(restarted.registry, restartedPending, (threads) => threads.has(THREAD_ID)); + }), + ); +}); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 84a290057f578..0e40c3405a275 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -7,9 +7,12 @@ import { WS_METHODS, type EnvironmentId, type OrchestrationV2ShellSnapshot, + type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import { createOptimisticThreadLifecycle } from "./threadLifecycle.ts"; +import type { EnvironmentShellStatus } from "./shell.ts"; +import { createThreadTriageOutbox } from "./threadTriageOutbox.ts"; import * as DateTime from "effect/DateTime"; import { @@ -143,9 +146,57 @@ export type { WatchThreadPullRequestInput, } from "../operations/commands.ts"; +function applySettle( + thread: OrchestrationV2ThreadShell, + now: DateTime.Utc, + accepted: boolean, +): OrchestrationV2ThreadShell { + return !accepted && + (thread.pendingRuntimeRequest !== null || + ["preparing", "queued", "starting", "running", "waiting"].includes(thread.status)) + ? thread + : { + ...thread, + pendingRuntimeRequest: null, + settledOverride: "settled", + settledAt: thread.settledOverride === "settled" ? (thread.settledAt ?? now) : now, + unsettledAt: null, + activeOrderKey: null, + pinnedAt: null, + pinOrderKey: null, + snoozedAt: null, + snoozedUntil: null, + }; +} + +function applySnooze( + thread: OrchestrationV2ThreadShell, + snoozedUntil: string, + now: DateTime.Utc, + accepted: boolean, +): OrchestrationV2ThreadShell { + return (!accepted && + (thread.pendingRuntimeRequest !== null || + ["preparing", "queued", "starting"].includes(thread.status))) || + !(Date.parse(snoozedUntil) > DateTime.toEpochMillis(now)) + ? thread + : { + ...thread, + pendingRuntimeRequest: null, + snoozedUntil: DateTime.makeUnsafe(snoozedUntil), + snoozedAt: now, + }; +} + export function createThreadEnvironmentAtoms( runtime: Atom.AtomRuntime, snapshotAtom: (environmentId: EnvironmentId) => Atom.Atom, + options: { + /** Offline settle and snooze replay once this reports a synchronized shell. */ + readonly shellStatusAtom?: (environmentId: EnvironmentId) => Atom.Atom; + /** Whether the loaded session can change threads; null until it loads. */ + readonly operateScopeAtom?: (environmentId: EnvironmentId) => Atom.Atom; + } = {}, ) { const scheduler = createAtomCommandScheduler(); const concurrency = { @@ -444,52 +495,57 @@ export function createThreadEnvironmentAtoms( concurrency, }), }; - const optimistic = createOptimisticThreadLifecycle(snapshotAtom); + const triage = createThreadTriageOutbox({ + runtime, + sourceSnapshotAtom: snapshotAtom, + ...options, + applySettle: (thread, queuedAt) => applySettle(thread, queuedAt, false), + applySnooze: (thread, snoozedUntil, queuedAt) => + applySnooze(thread, snoozedUntil, queuedAt, false), + replay: (registry, { environmentId, threadId, commandId, action, snapshotSequence }) => + action.type === "settle" + ? settle.run(registry, { environmentId, input: { threadId, commandId, snapshotSequence } }) + : snooze.run(registry, { + environmentId, + input: { threadId, commandId, snapshotSequence, snoozedUntil: action.snoozedUntil }, + }), + }); + const optimistic = createOptimisticThreadLifecycle(triage.snapshotAtom); + const settle = optimistic.wrap(commands.settle, (thread, _input, now, accepted) => + applySettle(thread, now, accepted), + ); + const snooze = optimistic.wrap(commands.snooze, (thread, input, now, accepted) => + applySnooze(thread, input.snoozedUntil, now, accepted), + ); return { ...commands, snapshotAtom: optimistic.snapshotAtom, - settle: optimistic.wrap(commands.settle, (thread, _input, now, accepted) => - !accepted && - (thread.pendingRuntimeRequest !== null || - ["preparing", "queued", "starting", "running", "waiting"].includes(thread.status)) - ? thread - : { - ...thread, - pendingRuntimeRequest: null, - settledOverride: "settled", - settledAt: thread.settledOverride === "settled" ? (thread.settledAt ?? now) : now, - unsettledAt: null, - activeOrderKey: null, - pinnedAt: null, - pinOrderKey: null, - snoozedAt: null, - snoozedUntil: null, - }, + pendingSyncAtom: triage.pendingSyncAtom, + offlineTriagePermittedAtom: triage.offlineTriagePermittedAtom, + settle: triage.queueWhenOffline(settle, () => ({ type: "settle" as const })), + unsettle: triage.cancelQueued( + optimistic.wrap(commands.unsettle, (thread, input, now) => ({ + ...thread, + settledOverride: input.reason === "user" ? "active" : null, + settledAt: null, + unsettledAt: thread.settledOverride === "active" ? (thread.unsettledAt ?? null) : now, + })), + "settle", + { sequence: 0 }, ), - unsettle: optimistic.wrap(commands.unsettle, (thread, input, now) => ({ - ...thread, - settledOverride: input.reason === "user" ? "active" : null, - settledAt: null, - unsettledAt: thread.settledOverride === "active" ? (thread.unsettledAt ?? null) : now, + snooze: triage.queueWhenOffline(snooze, (input) => ({ + type: "snooze" as const, + snoozedUntil: input.snoozedUntil, })), - snooze: optimistic.wrap(commands.snooze, (thread, input, now, accepted) => - (!accepted && - (thread.pendingRuntimeRequest !== null || - ["preparing", "queued", "starting"].includes(thread.status))) || - !(Date.parse(input.snoozedUntil) > DateTime.toEpochMillis(now)) - ? thread - : { - ...thread, - pendingRuntimeRequest: null, - snoozedUntil: DateTime.makeUnsafe(input.snoozedUntil), - snoozedAt: now, - }, + unsnooze: triage.cancelQueued( + optimistic.wrap(commands.unsnooze, (thread) => ({ + ...thread, + snoozedUntil: null, + snoozedAt: null, + })), + "snooze", + { sequence: 0 }, ), - unsnooze: optimistic.wrap(commands.unsnooze, (thread) => ({ - ...thread, - snoozedUntil: null, - snoozedAt: null, - })), setAutoSettle: optimistic.wrap(commands.setAutoSettle, (thread, input, now) => ({ ...thread, autoSettleDisabledAt: input.enabled ? null : (thread.autoSettleDisabledAt ?? now), diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts new file mode 100644 index 0000000000000..26b08787765e5 --- /dev/null +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -0,0 +1,379 @@ +import { + CommandId, + type EnvironmentId, + type OrchestrationV2ShellSnapshot, + type OrchestrationV2ThreadShell, + type ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; + +import { + type PendingThreadTriage, + type ThreadTriageKey, + type ThreadTriageOutboxDocument, + sameThreadTriage, + ThreadTriageOutboxStore, + type ThreadTriageOutboxStorageError, +} from "../platform/threadTriageOutboxStore.ts"; +import { EnvironmentRpcUnavailableError, isRpcClientError } from "../rpc/client.ts"; +import type { EnvironmentShellStatus } from "./shell.ts"; +import { type AtomCommand, type AtomCommandResult, createRuntimeCommand } from "./runtime.ts"; + +type SettleOrSnoozeInput = { + readonly threadId: ThreadId; + readonly commandId?: CommandId; + readonly snoozedUntil?: string; + readonly snapshotSequence?: number; +}; +type TriageResult = { readonly sequence: number; readonly pendingSync?: true }; +type Command = AtomCommand< + { readonly environmentId: EnvironmentId; readonly input: I }, + TriageResult, + E +>; + +type StoreOperation = + | { readonly _tag: "load" } + | { readonly _tag: "put"; readonly entry: Omit } + | { readonly _tag: "remove"; readonly entry: PendingThreadTriage } + | { + readonly _tag: "setOperable"; + readonly environmentId: EnvironmentId; + readonly operable: boolean; + }; + +const EMPTY_DOCUMENT: ThreadTriageOutboxDocument = { entries: [], operableEnvironmentIds: [] }; + +const isUnavailable = Schema.is(EnvironmentRpcUnavailableError); + +function isOffline(cause: Cause.Cause) { + return cause.reasons.some((reason) => Cause.isFailReason(reason) && isUnavailable(reason.error)); +} + +/** Failures that say nothing about whether the server would accept the change. */ +function isTransient(cause: Cause.Cause) { + return ( + Cause.hasInterruptsOnly(cause) || + cause.reasons.some( + (reason) => + !Cause.isFailReason(reason) || + isUnavailable(reason.error) || + isRpcClientError(reason.error), + ) + ); +} + +export function createThreadTriageOutbox(input: { + readonly runtime: Atom.AtomRuntime; + readonly sourceSnapshotAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom; + /** Replay waits for a synchronized shell; without one, nothing replays. */ + readonly shellStatusAtom?: (environmentId: EnvironmentId) => Atom.Atom; + /** Whether the loaded session can change threads; null until it loads. */ + readonly operateScopeAtom?: (environmentId: EnvironmentId) => Atom.Atom; + readonly applySettle: ( + thread: OrchestrationV2ThreadShell, + queuedAt: DateTime.Utc, + ) => OrchestrationV2ThreadShell; + readonly applySnooze: ( + thread: OrchestrationV2ThreadShell, + snoozedUntil: string, + queuedAt: DateTime.Utc, + ) => OrchestrationV2ThreadShell; + /** Sends the queued action with its command id and snapshot sequence. */ + readonly replay: ( + registry: AtomRegistry.AtomRegistry, + entry: PendingThreadTriage, + ) => Promise>; +}) { + const store = createRuntimeCommand(input.runtime, { + label: "environment-data:thread-triage-outbox", + execute: Effect.fn("ThreadTriageOutbox.store")(function* (operation: StoreOperation) { + const outbox = yield* ThreadTriageOutboxStore; + switch (operation._tag) { + case "load": + return yield* outbox.load.pipe( + Effect.catch((error) => + Effect.logWarning("Could not load offline thread changes.", { + cause: String(error.cause), + }).pipe(Effect.as(EMPTY_DOCUMENT)), + ), + ); + case "put": { + const crypto = yield* Crypto.Crypto; + const entry = { + ...operation.entry, + commandId: CommandId.make(yield* crypto.randomUUIDv4().pipe(Effect.orDie)), + }; + yield* outbox.put(entry).pipe( + Effect.tapError((error) => + Effect.logWarning("Could not save an offline thread change.", { + cause: String(error.cause), + }), + ), + ); + return { ...EMPTY_DOCUMENT, entries: [entry] }; + } + case "remove": + yield* outbox.remove(operation.entry).pipe( + Effect.tapError((error) => + Effect.logWarning("Could not remove an offline thread change.", { + cause: String(error.cause), + }), + ), + ); + return EMPTY_DOCUMENT; + case "setOperable": + yield* outbox.setOperable(operation.environmentId, operation.operable).pipe( + Effect.catch((error) => + Effect.logWarning("Could not remember thread permissions.", { + cause: String(error.cause), + }), + ), + ); + return EMPTY_DOCUMENT; + } + }), + }); + + const documentAtom = Atom.make(null).pipe(Atom.keepAlive); + let loading: Promise | undefined; + const ensureLoaded = (registry: AtomRegistry.AtomRegistry) => { + const loaded = registry.get(documentAtom); + if (loaded !== null) return Promise.resolve(loaded); + loading ??= store.run(registry, { _tag: "load" }).then((result) => { + const current = + registry.get(documentAtom) ?? + (AsyncResult.isSuccess(result) ? result.value : EMPTY_DOCUMENT); + registry.set(documentAtom, current); + return current; + }); + return loading; + }; + const loadedDocumentAtom = Atom.make((get) => { + const loaded = get(documentAtom); + if (loaded === null) void ensureLoaded(get.registry); + return loaded; + }); + + const entriesAtom = Atom.make((get) => get(loadedDocumentAtom)?.entries ?? []); + const environmentEntriesAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => get(entriesAtom).filter((entry) => entry.environmentId === environmentId)), + ); + + const replaying = new Set(); + const keyOf = (entry: ThreadTriageKey) => JSON.stringify([entry.environmentId, entry.threadId]); + const pendingFor = (registry: AtomRegistry.AtomRegistry, key: ThreadTriageKey) => + registry.get(documentAtom)?.entries.find((entry) => sameThreadTriage(entry, key)); + const setLocal = ( + registry: AtomRegistry.AtomRegistry, + transform: (entries: ReadonlyArray) => ReadonlyArray, + ) => { + const document = registry.get(documentAtom) ?? EMPTY_DOCUMENT; + registry.set(documentAtom, { ...document, entries: transform(document.entries) }); + }; + /** + * Hides the entry even if storage keeps it: a restart then replays it with the same command + * id, which the server deduplicates or rejects. Cancelling must check the result instead. + */ + const forget = async (registry: AtomRegistry.AtomRegistry, entry: PendingThreadTriage) => { + const removed = await store.run(registry, { _tag: "remove", entry }); + setLocal(registry, (entries) => entries.filter((current) => current !== entry)); + return removed; + }; + + const replayingEnvironments = new Set(); + const replayAgain = new Set(); + const replay = async (registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId) => { + if (replayingEnvironments.has(environmentId)) { + replayAgain.add(environmentId); + return; + } + replayingEnvironments.add(environmentId); + try { + await replayEntries(registry, environmentId); + } finally { + replayingEnvironments.delete(environmentId); + } + if (replayAgain.delete(environmentId) && input.shellStatusAtom !== undefined) { + if (registry.get(input.shellStatusAtom(environmentId)) === "live") { + await replay(registry, environmentId); + } + } + }; + const replayEntries = async ( + registry: AtomRegistry.AtomRegistry, + environmentId: EnvironmentId, + ) => { + for (const entry of registry.get(environmentEntriesAtom(environmentId))) { + const key = keyOf(entry); + if (replaying.has(key)) continue; + if ( + entry.action.type === "snooze" && + !(Date.parse(entry.action.snoozedUntil) > DateTime.toEpochMillis(DateTime.nowUnsafe())) + ) { + await forget(registry, entry); + continue; + } + replaying.add(key); + try { + const result = await input.replay(registry, entry); + if (AsyncResult.isSuccess(result) || !isTransient(result.cause)) { + await forget(registry, entry); + } else { + return; + } + } finally { + replaying.delete(key); + } + } + }; + + const replayAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const status = input.shellStatusAtom ? get(input.shellStatusAtom(environmentId)) : "empty"; + const entries = get(environmentEntriesAtom(environmentId)); + if (status === "live" && entries.length > 0) void replay(get.registry, environmentId); + }), + ); + + const snapshotAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + get.mount(replayAtom(environmentId)); + const snapshot = get(input.sourceSnapshotAtom(environmentId)); + const entries = get(environmentEntriesAtom(environmentId)); + if (snapshot === null || entries.length === 0) return snapshot; + const byThread = new Map(entries.map((entry) => [entry.threadId, entry])); + return { + ...snapshot, + threads: snapshot.threads.map((thread) => { + const entry = byThread.get(thread.id); + if (entry === undefined) return thread; + return entry.action.type === "settle" + ? input.applySettle(thread, entry.queuedAt) + : input.applySnooze(thread, entry.action.snoozedUntil, entry.queuedAt); + }), + }; + }), + ); + + const pendingSyncAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get): ReadonlySet => + new Set(get(environmentEntriesAtom(environmentId)).map((entry) => entry.threadId)), + ), + ); + + /** Queues the action locally when the environment is not connected. */ + function queueWhenOffline( + command: Command, + action: (input: I) => PendingThreadTriage["action"], + ): Command { + return { + label: command.label, + run: async (registry, target) => { + const key = { environmentId: target.environmentId, threadId: target.input.threadId }; + const previous = pendingFor(registry, key); + // Started before awaiting so the optimistic change shows in the same frame. + const running = command.run(registry, target); + if (previous !== undefined && !replaying.has(keyOf(previous))) { + await forget(registry, previous); + } + const result = await running; + if (AsyncResult.isSuccess(result) || !isOffline(result.cause)) return result; + await ensureLoaded(registry); + const snapshot = registry.get(input.sourceSnapshotAtom(target.environmentId)); + const thread = snapshot?.threads.find((candidate) => candidate.id === key.threadId); + if (snapshot == null || thread === undefined) return result; + const queued = await store.run(registry, { + _tag: "put", + entry: { + ...key, + action: action(target.input), + queuedAt: DateTime.nowUnsafe(), + snapshotSequence: snapshot.snapshotSequence, + }, + }); + if (!AsyncResult.isSuccess(queued)) return result; + const entry = queued.value.entries[0]!; + setLocal(registry, (entries) => [ + ...entries.filter((current) => !sameThreadTriage(current, entry)), + entry, + ]); + return AsyncResult.success({ sequence: snapshot.snapshotSequence, pendingSync: true }); + }, + }; + } + + /** Undo before sync cancels the queued action instead of asking the server. */ + function cancelQueued( + command: AtomCommand<{ readonly environmentId: EnvironmentId; readonly input: I }, A, CE>, + cancels: PendingThreadTriage["action"]["type"], + cancelledValue: A, + ): AtomCommand< + { readonly environmentId: EnvironmentId; readonly input: I }, + A, + CE | ThreadTriageOutboxStorageError | E + > { + return { + label: command.label, + run: async (registry, target) => { + const pending = pendingFor(registry, { + environmentId: target.environmentId, + threadId: target.input.threadId, + }); + if ( + pending === undefined || + pending.action.type !== cancels || + replaying.has(keyOf(pending)) + ) { + return command.run(registry, target); + } + // A cancel that stays on disk would come back and replay after a restart. + const removed = await store.run(registry, { _tag: "remove", entry: pending }); + if (!AsyncResult.isSuccess(removed)) return AsyncResult.failure(removed.cause); + setLocal(registry, (entries) => entries.filter((current) => current !== pending)); + return AsyncResult.success(cancelledValue); + }, + }; + } + + /** + * Settle and snooze stay available offline only where the last loaded session could + * change threads. A loaded session always decides, and replay is checked again by the server. + */ + const offlineTriagePermittedAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const known = input.operateScopeAtom ? get(input.operateScopeAtom(environmentId)) : null; + const document = get(loadedDocumentAtom); + const remembered = document?.operableEnvironmentIds.includes(environmentId) ?? false; + if (known === null) return remembered; + if (document !== null && known !== remembered) { + const registry = get.registry; + registry.set(documentAtom, { + ...document, + operableEnvironmentIds: [ + ...document.operableEnvironmentIds.filter((id) => id !== environmentId), + ...(known ? [environmentId] : []), + ], + }); + void store.run(registry, { _tag: "setOperable", environmentId, operable: known }); + } + return known; + }), + ); + + return { + snapshotAtom, + pendingSyncAtom, + offlineTriagePermittedAtom, + queueWhenOffline, + cancelQueued, + }; +} diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 771ade1902f25..5f75f2d1519c5 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -99,6 +99,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Hide the switch when absent; older servers silently drop per-project provider patches. */ projectProviderInstanceOverrides: Schema.optionalKey(Schema.Boolean), threadSnooze: Schema.optionalKey(Schema.Boolean), + /** Settle and snooze honor `snapshotSequence`; older servers ignore it. */ + threadTriageVersionGuard: Schema.optionalKey(Schema.Boolean), /** Clear published themes when absent; older servers never send updates. */ environmentThemes: Schema.optionalKey(Schema.Boolean), usageLimitSources: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index c5d59f817ed46..b9e3cf9c75110 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -2787,6 +2787,8 @@ export const OrchestrationV2Command = Schema.Union([ commandId: CommandId, threadId: ThreadId, settledAt: Schema.optional(Schema.DateTimeUtc), + /** Rejects the command if the thread has any event after this shell sequence. */ + snapshotSequence: Schema.optional(NonNegativeInt), }), /** Rejects changes since snapshotAt or any explicit settled override to preserve user actions. */ Schema.Struct({ @@ -2807,6 +2809,8 @@ export const OrchestrationV2Command = Schema.Union([ commandId: CommandId, threadId: ThreadId, snoozedUntil: IsoDateTime, + /** Rejects the command if the thread has any event after this shell sequence. */ + snapshotSequence: Schema.optional(NonNegativeInt), }), Schema.Struct({ type: Schema.Literal("thread.unsnooze"), From 49d939adb8ea6c4ba188ca7527e564ba71f840c8 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 15:18:58 +0000 Subject: [PATCH 02/44] fix: keep offline settle and snooze safe across replay, removal, and old servers - Replay rereads each queued change before sending it, and replay, Undo, and replacement claim a thread one at a time, so a change cancelled while an earlier one replays is never sent. - Removing an environment also clears its queued changes from memory, and in-flight work cannot add them back. - A replacement that cannot be saved keeps the earlier queued change. - Changes queue only when the cached server config says the server checks the snapshot; otherwise the action explains the server needs an update. A queued change that meets an older server stays queued. - Web context menus, bulk actions, the settle shortcut, and the chat view's wake and un-settle use the remembered permission for settle, snooze, and their undo only. Mobile's thread view does the same for wake and un-settle. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadDetailScreen.tsx | 12 +- apps/mobile/src/state/threads.ts | 2 + apps/web/src/components/Sidebar.tsx | 36 +++- .../components/chat/useThreadLifecycle.tsx | 16 +- .../components/threadActionMenu.logic.test.ts | 26 +++ .../src/components/threadActionMenu.logic.ts | 15 +- apps/web/src/hooks/useThreadActionMenu.ts | 8 +- apps/web/src/state/threads.ts | 18 +- .../src/platform/threadTriageOutboxStore.ts | 16 +- .../src/state/threadCommands.test.ts | 131 +++++++++++-- .../src/state/threadCommands.ts | 3 + .../src/state/threadTriageOutbox.ts | 177 +++++++++++++----- 12 files changed, 361 insertions(+), 99 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 40cdfc1e3f83d..699196791f68a 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -667,6 +667,11 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai }); const unsnoozeThread = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); const unsettleThread = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); + // Offline, wake and un-settle can still cancel or queue where the last session could operate. + const offlineTriagePermitted = useAtomValue( + threadEnvironment.offlineTriagePermittedAtom(props.environmentId), + ); + const canTriageThread = props.canOperateThread || offlineTriagePermitted; const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false }); const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const [statusRequest, setStatusRequest] = useState<{ threadKey: string } | null>(null); @@ -706,7 +711,9 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai latestRunCompletedAt: props.selectedThread.latestRun?.completedAt ?? null, }); const runStatusAction = async (action: "stop" | "wake" | "unsettle" | "dismiss") => { - if (statusPending || !props.canOperateThread) return; + const permitted = + action === "wake" || action === "unsettle" ? canTriageThread : props.canOperateThread; + if (statusPending || !permitted) return; const request = { threadKey: selectedThreadKey }; setStatusRequest(request); const target = { @@ -849,7 +856,8 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai : "Dismissing..." : actionLabel, accessibilityLabel: actionLabel, - disabled: !props.canOperateThread || statusPending, + disabled: + !(snoozed || settled ? canTriageThread : props.canOperateThread) || statusPending, onPress: () => void runStatusAction(snoozed ? "wake" : settled ? "unsettle" : "dismiss"), }, }; diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index fa4ba1398a7ab..517f2d28ff103 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -18,6 +18,7 @@ import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "./atom-registry"; import { environmentSession } from "./session"; +import { serverEnvironment } from "./server"; import { environmentShell } from "./shell"; export const threadFamilies = createThreadFamilyAtoms( @@ -34,6 +35,7 @@ export const threadEnvironment = createThreadEnvironmentAtoms( { shellStatusAtom: environmentShell.statusAtom, operateScopeAtom: environmentSession.operateScopeAtom, + serverConfigAtom: serverEnvironment.configValueAtom, }, ); export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 95da08df80b45..569114a2da942 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -134,7 +134,7 @@ import { import { environmentPresentations } from "../state/presentation"; import { primaryServerKeybindingsAtom, serverEnvironment } from "../state/server"; import { environmentProjects } from "../state/projects"; -import { threadEnvironment } from "../state/threads"; +import { readThreadTriageScope, threadEnvironment } from "../state/threads"; import { useOlderSettledShelf, useOlderSettledShelfGrowth } from "../state/olderSettledShelf"; import { useThreadSearch } from "../state/queries"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; @@ -150,6 +150,7 @@ import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; import { buildDraftActionMenuItems, buildThreadActionMenuItems, + isThreadTriageAction, threadActionRequiresOperate, } from "./threadActionMenu.logic"; import { @@ -761,18 +762,23 @@ function scheduleSnoozeWake(wakeAt: string | null, onWake: () => void): (() => v return () => window.clearTimeout(id); } +/** Settle, snooze and their undo can queue offline where the last session could operate. */ function canOperateThreads( threads: ReadonlyArray>, + triage = false, ): boolean { return threads.every((thread) => - readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope), + triage + ? readThreadTriageScope(thread.environmentId) + : readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope), ); } function checkThreadOperations( threads: ReadonlyArray>, + triage = false, ): boolean { - if (canOperateThreads(threads)) return true; + if (canOperateThreads(threads, triage)) return true; toastManager.add({ type: "error", title: "Thread action unavailable", @@ -1894,6 +1900,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const thread = threadByKey.get(key); return thread ? [thread] : []; }), + plan.kind === "settle", ) ) return; @@ -2097,25 +2104,25 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve { id: "settle", label: `Settle (${count})`, - disabled: settlingThreads.length === 0 || !canOperateThreads(settlingThreads), + disabled: settlingThreads.length === 0 || !canOperateThreads(settlingThreads, true), }, ...(canSnoozeSelection ? [ { id: "snooze", label: `Snooze (${count})`, - disabled: !canOperateThreads(selectedThreads), + disabled: !canOperateThreads(selectedThreads, true), children: [ ...snoozePresets.map((preset) => ({ id: `snooze:${preset.id}`, label: `${preset.label} (${preset.whenLabel})`, - disabled: !canOperateThreads(selectedThreads), + disabled: !canOperateThreads(selectedThreads, true), })), { id: "snooze:custom", label: "Custom…", separatorBefore: true, - disabled: !canOperateThreads(selectedThreads), + disabled: !canOperateThreads(selectedThreads, true), }, ], }, @@ -2157,7 +2164,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }) : selectedThreads; if (clicked.value === "settle" && actionTargets.length === 0) return; - if (clicked.value !== "mark-unread" && !checkThreadOperations(actionTargets)) return; + if ( + clicked.value !== "mark-unread" && + !checkThreadOperations(actionTargets, isThreadTriageAction(clicked.value)) + ) + return; if (clicked.value?.startsWith("snooze:")) { const preset = clicked.value === "snooze:custom" @@ -2395,6 +2406,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve threadRef.environmentId, AuthOrchestrationOperateScope, ), + canTriageOffline: appAtomRegistry.get( + threadEnvironment.offlineTriagePermittedAtom(threadRef.environmentId), + ), branch: thread.branch ?? null, projectFilter: threadProjectGroup ? { @@ -2425,7 +2439,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ), ); if (clicked._tag === "Failure" || clicked.value === null) return; - if (threadActionRequiresOperate(clicked.value) && !checkThreadOperations([thread])) return; + if ( + threadActionRequiresOperate(clicked.value) && + !checkThreadOperations([thread], isThreadTriageAction(clicked.value)) + ) + return; if (clicked.value?.startsWith("snooze:")) { const preset = clicked.value === "snooze:custom" diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index 8e9af1b2d8997..824151c546d58 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -43,7 +43,7 @@ import { newMessageId } from "~/lib/utils"; import { buildThreadRouteParams } from "../../threadRoutes"; import { composerDraftHasUserContent, useComposerDraftStore } from "../../composerDraftStore"; import { useEnvironmentScope } from "~/state/session"; -import { threadEnvironment } from "../../state/threads"; +import { readThreadTriageScope, threadEnvironment } from "../../state/threads"; import { vcsEnvironment } from "../../state/vcs"; import { useChildThreadInputs, readThreadShell, waitForThreadShell } from "../../state/entities"; import { environmentShell } from "../../state/shell"; @@ -224,11 +224,7 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { const [unsettlingThreadKey, setUnsettlingThreadKey] = useState(null); const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey; const handleUnsettleActiveThread = async () => { - if ( - !activeThreadRef || - !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) - ) - return; + if (!activeThreadRef || !readThreadTriageScope(activeThreadRef.environmentId)) return; const threadKey = scopedThreadKey(activeThreadRef); setUnsettlingThreadKey(threadKey); // Commands settle into a result, so this always runs. @@ -255,11 +251,7 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState(null); const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey; const handleUnsnoozeActiveThread = async () => { - if ( - !activeThreadRef || - !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) - ) - return; + if (!activeThreadRef || !readThreadTriageScope(activeThreadRef.environmentId)) return; const threadKey = scopedThreadKey(activeThreadRef); setUnsnoozingThreadKey(threadKey); // Commands settle into a result, so this always runs. @@ -578,7 +570,7 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { }; const shortcuts = { "thread.settle": () => { - if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false; + if (!readThreadTriageScope(environmentId)) return false; if (!activeThreadShell || !activeThreadRef || !supportsSettlement) return; if (activeThreadSettled) { void handleUnsettleActiveThread(); diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 039c684dcdc36..260d3f341d293 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -65,6 +65,32 @@ describe("buildThreadActionMenuItems", () => { }, ); + it.each([false, true])( + "keeps only settle and snooze directions offline where triage is remembered (reversed: %s)", + (reversed) => { + const items = buildThreadActionMenuItems({ + ...baseState, + canOperate: false, + canTriageOffline: true, + isPinned: reversed, + isSettled: reversed, + isSnoozed: reversed, + }); + expect(items.filter((item) => item.disabled).map((item) => item.id)).toEqual([ + reversed ? "unpin" : "pin", + "rename", + "regenerate-title", + "auto-settle", + "archive", + "delete", + ]); + expect( + items.find((item) => item.id === "snooze")?.children?.some((child) => child.disabled) ?? + false, + ).toBe(false); + }, + ); + it("disables archive while the thread is running", () => { const archiveItem = buildThreadActionMenuItems({ ...baseState, isRunning: true }).find( (item) => item.id === "archive", diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index eab2dafd91f46..cbad00930b795 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -66,6 +66,8 @@ export function buildDraftActionMenuItems(options: { export interface ThreadActionMenuState { readonly canOperate: boolean; + /** Offline, settle, snooze and their undo can queue where the last session could operate. */ + readonly canTriageOffline?: boolean; readonly branch: string | null; readonly projectFilter: { readonly label: string; @@ -105,6 +107,16 @@ export function threadActionRequiresOperate(action: ThreadActionMenuId): boolean ].includes(action); } +export function isThreadTriageAction(action: string): boolean { + return ( + action === "settle" || + action === "unsettle" || + action === "snooze" || + action === "unsnooze" || + action.startsWith("snooze:") + ); +} + export function buildThreadActionMenuItems( state: ThreadActionMenuState, ): ReadonlyArray> { @@ -229,7 +241,8 @@ export function buildThreadActionMenuItems( return state.canOperate ? items : items.map((item) => - threadActionRequiresOperate(item.id) + threadActionRequiresOperate(item.id) && + !(state.canTriageOffline === true && isThreadTriageAction(item.id)) ? { ...item, disabled: true, diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 0804fc007e1a6..571b20ae8a304 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -18,11 +18,12 @@ import { useRouter } from "@tanstack/react-router"; import { resolveSnoozePresets } from "../components/Sidebar.snooze"; import { buildThreadActionMenuItems, + isThreadTriageAction, threadActionRequiresOperate, type ThreadActionMenuId, } from "../components/threadActionMenu.logic"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; -import { threadEnvironment } from "../state/threads"; +import { readThreadTriageScope, threadEnvironment } from "../state/threads"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { readEnvironmentScope } from "../state/session"; import { @@ -132,6 +133,7 @@ export function useThreadActionMenu(input: { const snoozePresets = resolveSnoozePresets(now, timestampFormat); const items = buildThreadActionMenuItems({ canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), + canTriageOffline: readThreadTriageScope(threadRef.environmentId), branch: thread.branch ?? null, projectFilter: null, isPinned: thread.pinnedAt != null, @@ -150,7 +152,9 @@ export function useThreadActionMenu(input: { const action: ThreadActionMenuId = clicked.value; if ( threadActionRequiresOperate(action) && - !readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) + !(isThreadTriageAction(action) + ? readThreadTriageScope(threadRef.environmentId) + : readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) ) { failureToast( "Thread action unavailable", diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index abd42ab36c95f..c58f3c9386094 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -11,7 +11,12 @@ import { createThreadEnvironmentAtoms, createThreadFamilyAtoms, } from "@t3tools/client-runtime/state/threads"; -import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + type EnvironmentId, + type OrchestrationV2ThreadShell, + type ThreadId, +} from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useEffect } from "react"; @@ -19,7 +24,8 @@ import { useEffect } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { environmentSession } from "./session"; +import { environmentSession, readEnvironmentScope } from "./session"; +import { serverEnvironment } from "./server"; import { environmentShell } from "./shell"; export const threadFamilies = createThreadFamilyAtoms( @@ -36,8 +42,16 @@ export const threadEnvironment = createThreadEnvironmentAtoms( { shellStatusAtom: environmentShell.statusAtom, operateScopeAtom: environmentSession.operateScopeAtom, + serverConfigAtom: serverEnvironment.configValueAtom, }, ); +/** Settle, snooze and their undo can queue offline where the last session could operate. */ +export function readThreadTriageScope(environmentId: EnvironmentId): boolean { + return ( + readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || + appAtomRegistry.get(threadEnvironment.offlineTriagePermittedAtom(environmentId)) + ); +} const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( environmentThreads.stateAtom, diff --git a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts index 6431e8f3c78fb..c32b16f59008f 100644 --- a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts +++ b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts @@ -63,6 +63,8 @@ export interface ThreadTriageOutboxStoreShape { readonly clearEnvironment: ( environmentId: EnvironmentId, ) => Effect.Effect; + /** Lets in-memory copies drop a removed environment's entries; returns the unsubscribe. */ + readonly subscribeCleared: (listener: (environmentId: EnvironmentId) => void) => () => void; } export class ThreadTriageOutboxStore extends Context.Reference( @@ -86,6 +88,7 @@ export function makeThreadTriageOutboxStore(storage: { readonly write: (value: string) => Effect.Effect; }): ThreadTriageOutboxStoreShape { const lock = Semaphore.makeUnsafe(1); + const clearedListeners = new Set<(environmentId: EnvironmentId) => void>(); const load = storage.read.pipe( Effect.flatMap((raw) => raw === null @@ -142,6 +145,17 @@ export function makeThreadTriageOutboxStore(storage: { operableEnvironmentIds: document.operableEnvironmentIds.filter( (id) => id !== environmentId, ), - })), + })).pipe( + // The environment is gone even if storage failed, so memory must not replay it. + Effect.ensuring( + Effect.sync(() => { + for (const listener of clearedListeners) listener(environmentId); + }), + ), + ), + subscribeCleared: (listener) => { + clearedListeners.add(listener); + return () => clearedListeners.delete(listener); + }, }; } diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index eaa95d4431145..f5e6034276266 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -10,6 +10,7 @@ import { ThreadId, type OrchestrationV2Command, type OrchestrationV2ShellSnapshot, + type ServerConfig, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; @@ -102,16 +103,21 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio }; readonly versionGuard?: boolean; readonly operateScope?: boolean | null; + /** The guard capability in the cached config, which may differ from the live server's. */ + readonly cachedVersionGuard?: boolean; }) { const requests = yield* Queue.unbounded<{ command: OrchestrationV2Command; reply: Deferred.Deferred<{ sequence: number }, Error>; }>(); const reconciled = yield* Queue.unbounded(); + const configReads = yield* Queue.unbounded(); const liveSession = { - initialConfig: Effect.succeed({ - environment: { capabilities: { threadTriageVersionGuard: options?.versionGuard ?? true } }, - }), + initialConfig: Queue.offer(configReads, undefined).pipe( + Effect.as({ + environment: { capabilities: { threadTriageVersionGuard: options?.versionGuard ?? true } }, + }), + ), client: { [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) => Effect.gen(function* () { @@ -129,6 +135,16 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio session, } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]); const storage = options?.storage ?? { value: null }; + const store = makeThreadTriageOutboxStore({ + read: Effect.sync(() => storage.value), + write: (value) => + storage.failWrites + ? Effect.fail(new ThreadTriageOutboxStorageError({ cause: "disk full" })) + : Effect.sync(() => { + storage.value = value; + if (storage.writes) Queue.offerUnsafe(storage.writes, value); + }), + }); const runtime = Atom.runtime( Layer.mergeAll( Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, { @@ -142,19 +158,7 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio digest: (_algorithm, data) => Effect.succeed(data), }), ), - Layer.succeed( - ThreadTriageOutboxStore, - makeThreadTriageOutboxStore({ - read: Effect.sync(() => storage.value), - write: (value) => - storage.failWrites - ? Effect.fail(new ThreadTriageOutboxStorageError({ cause: "disk full" })) - : Effect.sync(() => { - storage.value = value; - if (storage.writes) Queue.offerUnsafe(storage.writes, value); - }), - }), - ), + Layer.succeed(ThreadTriageOutboxStore, store), Layer.succeed( ThreadHistoryController.ThreadHistoryController, ThreadHistoryController.ThreadHistoryController.of({ @@ -174,9 +178,17 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio const operateScopeAtom = Atom.family((_environmentId: EnvironmentId) => Atom.make(options?.operateScope === undefined ? true : options.operateScope), ); + const serverConfigAtom = Atom.family((_environmentId: EnvironmentId) => + Atom.make({ + environment: { + capabilities: { threadTriageVersionGuard: options?.cachedVersionGuard ?? true }, + }, + } as unknown as ServerConfig), + ); const commands = createThreadEnvironmentAtoms(runtime, snapshotAtom, { shellStatusAtom: statusAtom, operateScopeAtom, + serverConfigAtom, }); const registry = AtomRegistry.make(); yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); @@ -199,6 +211,8 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio requests, reconciled, storage, + store, + configReads, reconnect, disconnect, }; @@ -717,16 +731,97 @@ describe("settle and snooze while the environment is offline", () => { }), ); - it.effect("drops a queued change when the server cannot check versions", () => + it.effect("refuses to queue for a server that cannot check the snapshot", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false, cachedVersionGuard: false }); + const result = yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + expect(result._tag === "Failure" ? String(Cause.squash(result.cause)) : "Success").toContain( + "Mac is not connected. Update its server to settle or snooze while it is offline.", + ); + expect(h.registry.get(h.commands.pendingSyncAtom(ENVIRONMENT_ID)).size).toBe(0); + expect(h.registry.get(h.visibleAtom)).toBe(SNAPSHOT); + }), + ); + + it.effect("keeps a queued change when the reconnected server cannot check it", () => Effect.gen(function* () { const h = yield* makeHarness({ connected: false, versionGuard: false }); const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); h.registry.mount(pending); yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); yield* h.reconnect(); - yield* waitFor(h.registry, pending, (threads) => threads.size === 0); + yield* Queue.take(h.configReads); + // A second reconnect only replays after the first attempt finished. + yield* h.disconnect; + yield* h.reconnect(); + yield* Queue.take(h.configReads); expect(yield* Queue.size(h.requests)).toBe(0); + expect(h.registry.get(pending).has(THREAD_ID)).toBe(true); + expect(stored(h.storage)).toHaveLength(1); + }), + ); + + it.effect("does not send a queued change cancelled while an earlier one replays", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false }); + const cancelledThreadId = ThreadId.make("cancelled-thread"); + const laterThreadId = ThreadId.make("later-thread"); + h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), { + ...SNAPSHOT, + threads: [ + ...SNAPSHOT.threads, + { ...SNAPSHOT.threads[0]!, id: cancelledThreadId }, + { ...SNAPSHOT.threads[0]!, id: laterThreadId }, + ], + }); + h.registry.mount(h.commands.pendingSyncAtom(ENVIRONMENT_ID)); + for (const threadId of [THREAD_ID, cancelledThreadId, laterThreadId]) { + yield* Effect.promise(() => + h.commands.settle.run(h.registry, { environmentId: ENVIRONMENT_ID, input: { threadId } }), + ); + } + yield* h.reconnect(); + const first = yield* Queue.take(h.requests); + expect(first.command).toMatchObject({ type: "thread.settle", threadId: THREAD_ID }); + const undo = yield* Effect.promise(() => + h.commands.unsettle.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: cancelledThreadId, reason: "user" }, + }), + ); + expect(undo._tag).toBe("Success"); + yield* Deferred.succeed(first.reply, { sequence: 2 }); + const next = yield* Queue.take(h.requests); + expect(next.command).toMatchObject({ type: "thread.settle", threadId: laterThreadId }); + }), + ); + + it.effect("forgets an environment's queued changes in memory when it is removed", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false }); + const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); + h.registry.mount(pending); + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + yield* h.store.clearEnvironment(ENVIRONMENT_ID); + expect(h.registry.get(pending).size).toBe(0); expect(h.registry.get(h.visibleAtom)).toBe(SNAPSHOT); + expect(stored(h.storage)).toEqual([]); + }), + ); + + it.effect("keeps the earlier queued change when its replacement cannot be saved", () => + Effect.gen(function* () { + const storage = { value: null as string | null, failWrites: false }; + const h = yield* makeHarness({ connected: false, storage }); + const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); + h.registry.mount(pending); + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + storage.failWrites = true; + const replacement = yield* Effect.promise(() => h.commands.snooze.run(h.registry, snooze)); + expect(replacement._tag).toBe("Failure"); + expect(h.registry.get(pending).has(THREAD_ID)).toBe(true); + expect(h.registry.get(h.visibleAtom)?.threads[0]?.settledOverride).toBe("settled"); + expect(stored(storage)).toMatchObject([{ action: { type: "settle" } }]); }), ); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 0e40c3405a275..d0bdabbc92eeb 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -8,6 +8,7 @@ import { type EnvironmentId, type OrchestrationV2ShellSnapshot, type OrchestrationV2ThreadShell, + type ServerConfig, } from "@t3tools/contracts"; import { createOptimisticThreadLifecycle } from "./threadLifecycle.ts"; @@ -196,6 +197,8 @@ export function createThreadEnvironmentAtoms( readonly shellStatusAtom?: (environmentId: EnvironmentId) => Atom.Atom; /** Whether the loaded session can change threads; null until it loads. */ readonly operateScopeAtom?: (environmentId: EnvironmentId) => Atom.Atom; + /** Cached config; offline changes queue only for servers that can check them. */ + readonly serverConfigAtom?: (environmentId: EnvironmentId) => Atom.Atom; } = {}, ) { const scheduler = createAtomCommandScheduler(); diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index 26b08787765e5..36cdf201b3539 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -3,6 +3,7 @@ import { type EnvironmentId, type OrchestrationV2ShellSnapshot, type OrchestrationV2ThreadShell, + type ServerConfig, type ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -20,6 +21,7 @@ import { ThreadTriageOutboxStore, type ThreadTriageOutboxStorageError, } from "../platform/threadTriageOutboxStore.ts"; +import { ThreadVersionGuardUnsupportedError } from "../operations/commands.ts"; import { EnvironmentRpcUnavailableError, isRpcClientError } from "../rpc/client.ts"; import type { EnvironmentShellStatus } from "./shell.ts"; import { type AtomCommand, type AtomCommandResult, createRuntimeCommand } from "./runtime.ts"; @@ -50,12 +52,21 @@ type StoreOperation = const EMPTY_DOCUMENT: ThreadTriageOutboxDocument = { entries: [], operableEnvironmentIds: [] }; const isUnavailable = Schema.is(EnvironmentRpcUnavailableError); +const isGuardUnsupported = Schema.is(ThreadVersionGuardUnsupportedError); -function isOffline(cause: Cause.Cause) { - return cause.reasons.some((reason) => Cause.isFailReason(reason) && isUnavailable(reason.error)); +function offlineError(cause: Cause.Cause) { + const reason = cause.reasons.find( + (candidate) => Cause.isFailReason(candidate) && isUnavailable(candidate.error), + ); + return reason !== undefined && Cause.isFailReason(reason) && isUnavailable(reason.error) + ? reason.error + : undefined; } -/** Failures that say nothing about whether the server would accept the change. */ +/** + * Failures that say nothing about whether the server would accept the change. A server too old + * to check the snapshot keeps the change queued until it is updated. + */ function isTransient(cause: Cause.Cause) { return ( Cause.hasInterruptsOnly(cause) || @@ -63,7 +74,8 @@ function isTransient(cause: Cause.Cause) { (reason) => !Cause.isFailReason(reason) || isUnavailable(reason.error) || - isRpcClientError(reason.error), + isRpcClientError(reason.error) || + isGuardUnsupported(reason.error), ) ); } @@ -77,6 +89,8 @@ export function createThreadTriageOutbox(input: { readonly shellStatusAtom?: (environmentId: EnvironmentId) => Atom.Atom; /** Whether the loaded session can change threads; null until it loads. */ readonly operateScopeAtom?: (environmentId: EnvironmentId) => Atom.Atom; + /** Cached config; only servers that check the snapshot sequence get queued changes. */ + readonly serverConfigAtom?: (environmentId: EnvironmentId) => Atom.Atom; readonly applySettle: ( thread: OrchestrationV2ThreadShell, queuedAt: DateTime.Utc, @@ -94,10 +108,14 @@ export function createThreadTriageOutbox(input: { }) { const store = createRuntimeCommand(input.runtime, { label: "environment-data:thread-triage-outbox", - execute: Effect.fn("ThreadTriageOutbox.store")(function* (operation: StoreOperation) { + execute: Effect.fn("ThreadTriageOutbox.store")(function* ( + operation: StoreOperation, + registry: AtomRegistry.AtomRegistry, + ) { const outbox = yield* ThreadTriageOutboxStore; switch (operation._tag) { case "load": + outbox.subscribeCleared((environmentId) => clearLocal(registry, environmentId)); return yield* outbox.load.pipe( Effect.catch((error) => Effect.logWarning("Could not load offline thread changes.", { @@ -167,7 +185,19 @@ export function createThreadTriageOutbox(input: { Atom.make((get) => get(entriesAtom).filter((entry) => entry.environmentId === environmentId)), ); - const replaying = new Set(); + /** One owner per thread at a time: a replay, an Undo, or a replacing action. */ + const claimed = new Set(); + /** Bumped when an environment is removed so in-flight work does not re-add its entries. */ + const generations = new Map(); + const clearLocal = (registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId) => { + generations.set(environmentId, (generations.get(environmentId) ?? 0) + 1); + const document = registry.get(documentAtom); + if (document === null) return; + registry.set(documentAtom, { + entries: document.entries.filter((entry) => entry.environmentId !== environmentId), + operableEnvironmentIds: document.operableEnvironmentIds.filter((id) => id !== environmentId), + }); + }; const keyOf = (entry: ThreadTriageKey) => JSON.stringify([entry.environmentId, entry.threadId]); const pendingFor = (registry: AtomRegistry.AtomRegistry, key: ThreadTriageKey) => registry.get(documentAtom)?.entries.find((entry) => sameThreadTriage(entry, key)); @@ -211,18 +241,21 @@ export function createThreadTriageOutbox(input: { registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId, ) => { - for (const entry of registry.get(environmentEntriesAtom(environmentId))) { + for (const { threadId } of registry.get(environmentEntriesAtom(environmentId))) { + // Earlier replies may have let Undo, a replacement, or a removal change this entry. + const entry = pendingFor(registry, { environmentId, threadId }); + if (entry === undefined) continue; const key = keyOf(entry); - if (replaying.has(key)) continue; - if ( - entry.action.type === "snooze" && - !(Date.parse(entry.action.snoozedUntil) > DateTime.toEpochMillis(DateTime.nowUnsafe())) - ) { - await forget(registry, entry); - continue; - } - replaying.add(key); + if (claimed.has(key)) continue; + claimed.add(key); try { + if ( + entry.action.type === "snooze" && + !(Date.parse(entry.action.snoozedUntil) > DateTime.toEpochMillis(DateTime.nowUnsafe())) + ) { + await forget(registry, entry); + continue; + } const result = await input.replay(registry, entry); if (AsyncResult.isSuccess(result) || !isTransient(result.cause)) { await forget(registry, entry); @@ -230,7 +263,7 @@ export function createThreadTriageOutbox(input: { return; } } finally { - replaying.delete(key); + claimed.delete(key); } } }; @@ -246,6 +279,8 @@ export function createThreadTriageOutbox(input: { const snapshotAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => { get.mount(replayAtom(environmentId)); + // Loads the cached config before an offline action needs to read it. + if (input.serverConfigAtom) get.mount(input.serverConfigAtom(environmentId)); const snapshot = get(input.sourceSnapshotAtom(environmentId)); const entries = get(environmentEntriesAtom(environmentId)); if (snapshot === null || entries.length === 0) return snapshot; @@ -274,39 +309,69 @@ export function createThreadTriageOutbox(input: { function queueWhenOffline( command: Command, action: (input: I) => PendingThreadTriage["action"], - ): Command { + ): Command { return { label: command.label, run: async (registry, target) => { - const key = { environmentId: target.environmentId, threadId: target.input.threadId }; - const previous = pendingFor(registry, key); - // Started before awaiting so the optimistic change shows in the same frame. - const running = command.run(registry, target); - if (previous !== undefined && !replaying.has(keyOf(previous))) { - await forget(registry, previous); + const { environmentId } = target; + const key = { environmentId, threadId: target.input.threadId }; + const claimKey = keyOf(key); + // A replay already holding the thread finishes first; the scheduler runs this after it. + const owns = !claimed.has(claimKey); + if (owns) claimed.add(claimKey); + const generation = generations.get(environmentId) ?? 0; + const current = () => (generations.get(environmentId) ?? 0) === generation; + try { + // Started before any await so the optimistic change shows in the same frame. + const result = await command.run(registry, target); + if (AsyncResult.isSuccess(result)) { + // The server now has a newer action, which a queued one would only fight. + const previous = owns ? pendingFor(registry, key) : undefined; + if (previous !== undefined) await forget(registry, previous); + return result; + } + const offline = offlineError(result.cause); + if (offline === undefined) return result; + const config = input.serverConfigAtom + ? registry.get(input.serverConfigAtom(environmentId)) + : null; + if (config?.environment.capabilities.threadTriageVersionGuard !== true) { + return AsyncResult.failure( + Cause.fail( + new ThreadVersionGuardUnsupportedError({ + message: `${offline.message} Update its server to settle or snooze while it is offline.`, + }), + ), + ); + } + await ensureLoaded(registry); + const snapshot = registry.get(input.sourceSnapshotAtom(environmentId)); + const thread = snapshot?.threads.find((candidate) => candidate.id === key.threadId); + if (snapshot == null || thread === undefined || !current()) return result; + // The put replaces any earlier entry for the thread, so a failed save keeps it. + const queued = await store.run(registry, { + _tag: "put", + entry: { + ...key, + action: action(target.input), + queuedAt: DateTime.nowUnsafe(), + snapshotSequence: snapshot.snapshotSequence, + }, + }); + if (!AsyncResult.isSuccess(queued)) return result; + const entry = queued.value.entries[0]!; + if (!current()) { + await store.run(registry, { _tag: "remove", entry }); + return result; + } + setLocal(registry, (entries) => [ + ...entries.filter((existing) => !sameThreadTriage(existing, entry)), + entry, + ]); + return AsyncResult.success({ sequence: snapshot.snapshotSequence, pendingSync: true }); + } finally { + if (owns) claimed.delete(claimKey); } - const result = await running; - if (AsyncResult.isSuccess(result) || !isOffline(result.cause)) return result; - await ensureLoaded(registry); - const snapshot = registry.get(input.sourceSnapshotAtom(target.environmentId)); - const thread = snapshot?.threads.find((candidate) => candidate.id === key.threadId); - if (snapshot == null || thread === undefined) return result; - const queued = await store.run(registry, { - _tag: "put", - entry: { - ...key, - action: action(target.input), - queuedAt: DateTime.nowUnsafe(), - snapshotSequence: snapshot.snapshotSequence, - }, - }); - if (!AsyncResult.isSuccess(queued)) return result; - const entry = queued.value.entries[0]!; - setLocal(registry, (entries) => [ - ...entries.filter((current) => !sameThreadTriage(current, entry)), - entry, - ]); - return AsyncResult.success({ sequence: snapshot.snapshotSequence, pendingSync: true }); }, }; } @@ -328,18 +393,26 @@ export function createThreadTriageOutbox(input: { environmentId: target.environmentId, threadId: target.input.threadId, }); + const claimKey = pending === undefined ? undefined : keyOf(pending); if ( pending === undefined || + claimKey === undefined || pending.action.type !== cancels || - replaying.has(keyOf(pending)) + claimed.has(claimKey) ) { + // A claimed entry may already be on its way; the server undoes it after it lands. return command.run(registry, target); } - // A cancel that stays on disk would come back and replay after a restart. - const removed = await store.run(registry, { _tag: "remove", entry: pending }); - if (!AsyncResult.isSuccess(removed)) return AsyncResult.failure(removed.cause); - setLocal(registry, (entries) => entries.filter((current) => current !== pending)); - return AsyncResult.success(cancelledValue); + claimed.add(claimKey); + try { + // A cancel that stays on disk would come back and replay after a restart. + const removed = await store.run(registry, { _tag: "remove", entry: pending }); + if (!AsyncResult.isSuccess(removed)) return AsyncResult.failure(removed.cause); + setLocal(registry, (entries) => entries.filter((current) => current !== pending)); + return AsyncResult.success(cancelledValue); + } finally { + claimed.delete(claimKey); + } }, }; } From 2d3a41140f09845d00b4d122e6f991ece0d0a34c Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 15:28:58 +0000 Subject: [PATCH 03/44] fix: keep removed environments out of offline triage loads, and enable web triage controls - The first load of queued changes drops environments removed while the read was in flight, along with their remembered permission, instead of publishing the stale document. - The chat view's Wake now and Un-settle buttons, and sidebar dragging, use the remembered offline permission. Only a drop onto Settled can queue; the drop handler keeps other moves live-only. Co-Authored-By: Claude Opus 5.5 --- .../components/chat/useThreadLifecycle.tsx | 8 +++- .../components/sidebar/SidebarThreadRow.tsx | 6 ++- .../src/state/threadCommands.test.ts | 45 ++++++++++++++++++- .../src/state/threadTriageOutbox.ts | 19 +++++--- 4 files changed, 68 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index 824151c546d58..cb8a1a64bad4d 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { Dispatch } from "react"; @@ -152,6 +153,9 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { const router = useRouter(); const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); + // Offline, wake and un-settle can still cancel or queue where the last session could operate. + const canTriageThread = + useAtomValue(threadEnvironment.offlineTriagePermittedAtom(environmentId)) || canOperateThread; const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, @@ -536,7 +540,7 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { label={snoozedUntil ? `Snoozed, ${formatRelativeTimeUntilLabel(snoozedUntil)}` : "Snoozed"} clockMinute={nowMinute} actionLabel={isUnsnoozing ? "Waking..." : "Wake now"} - actionDisabled={!canOperateThread || isUnsnoozing} + actionDisabled={!canTriageThread || isUnsnoozing} onAction={() => void handleUnsnoozeActiveThreadStable()} /> ) : activeThreadSettled ? ( @@ -545,7 +549,7 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { label={settledAt ? `Settled ${formatRelativeTimeLabel(settledAt)}` : "Settled"} clockMinute={nowMinute} actionLabel={isUnsettling ? "Un-settling..." : "Un-settle"} - actionDisabled={!canOperateThread || isUnsettling} + actionDisabled={!canTriageThread || isUnsettling} onAction={() => void handleUnsettleActiveThreadStable()} /> ) : activeThreadWokeVisible ? ( diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 8ccab7f84697b..36bc06d325df2 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -459,10 +459,14 @@ export function SortableThreadRow( rowProps.thread.environmentId, AuthOrchestrationOperateScope, ); + // Offline, a drop onto Settled can still queue; the drop handler keeps other moves live-only. + const canTriageThread = useAtomValue( + threadEnvironment.offlineTriagePermittedAtom(rowProps.thread.environmentId), + ); const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: sortableId, disabled: - sortableDisabled || !canOperateThread + sortableDisabled || !(canOperateThread || canTriageThread) ? SIDEBAR_SORTABLE_DRAG_DISABLED : SIDEBAR_SORTABLE_DRAG_ENABLED, animateLayoutChanges: animateSidebarLayoutChanges, diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index f5e6034276266..e0513ded3c854 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -35,6 +35,7 @@ import { makeThreadTriageOutboxStore, ThreadTriageOutboxStorageError, ThreadTriageOutboxStore, + type ThreadTriageOutboxStoreShape, } from "../platform/threadTriageOutboxStore.ts"; import * as ThreadHistoryController from "./threadHistoryController.ts"; @@ -105,6 +106,7 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio readonly operateScope?: boolean | null; /** The guard capability in the cached config, which may differ from the live server's. */ readonly cachedVersionGuard?: boolean; + readonly wrapStore?: (store: ThreadTriageOutboxStoreShape) => ThreadTriageOutboxStoreShape; }) { const requests = yield* Queue.unbounded<{ command: OrchestrationV2Command; @@ -158,7 +160,7 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio digest: (_algorithm, data) => Effect.succeed(data), }), ), - Layer.succeed(ThreadTriageOutboxStore, store), + Layer.succeed(ThreadTriageOutboxStore, options?.wrapStore?.(store) ?? store), Layer.succeed( ThreadHistoryController.ThreadHistoryController, ThreadHistoryController.ThreadHistoryController.of({ @@ -900,4 +902,45 @@ describe("settle and snooze while the environment is offline", () => { yield* waitFor(restarted.registry, restartedPending, (threads) => threads.has(THREAD_ID)); }), ); + + it.effect("does not restore changes of an environment removed while they load", () => + Effect.gen(function* () { + const writes = yield* Queue.unbounded(); + const storage = { value: null as string | null, writes }; + const before = yield* makeHarness({ connected: false, storage, operateScope: true }); + before.registry.mount(before.commands.offlineTriagePermittedAtom(ENVIRONMENT_ID)); + yield* Effect.promise(() => before.commands.settle.run(before.registry, settle)); + expect(stored(storage)).toHaveLength(1); + + // The removal lands after the read but before the loaded document is published. + const h = yield* makeHarness({ + connected: false, + storage, + operateScope: null, + wrapStore: (store) => ({ + ...store, + load: store.load.pipe(Effect.tap(() => store.clearEnvironment(ENVIRONMENT_ID))), + }), + }); + const laterThreadId = ThreadId.make("later-thread"); + h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), { + ...SNAPSHOT, + threads: [...SNAPSHOT.threads, { ...SNAPSHOT.threads[0]!, id: laterThreadId }], + }); + const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); + const permitted = h.commands.offlineTriagePermittedAtom(ENVIRONMENT_ID); + h.registry.mount(pending); + h.registry.mount(permitted); + // Queueing another thread waits for the load to publish its document. + yield* Effect.promise(() => + h.commands.settle.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: laterThreadId }, + }), + ); + expect([...h.registry.get(pending)]).toEqual([laterThreadId]); + expect(stored(storage)).toMatchObject([{ threadId: laterThreadId }]); + expect(h.registry.get(permitted)).toBe(false); + }), + ); }); diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index 36cdf201b3539..96025740f1d43 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -160,15 +160,24 @@ export function createThreadTriageOutbox(input: { }), }); + /** Bumped when an environment is removed so in-flight work does not re-add its entries. */ + const generations = new Map(); const documentAtom = Atom.make(null).pipe(Atom.keepAlive); let loading: Promise | undefined; const ensureLoaded = (registry: AtomRegistry.AtomRegistry) => { const loaded = registry.get(documentAtom); if (loaded !== null) return Promise.resolve(loaded); - loading ??= store.run(registry, { _tag: "load" }).then((result) => { - const current = - registry.get(documentAtom) ?? - (AsyncResult.isSuccess(result) ? result.value : EMPTY_DOCUMENT); + if (loading !== undefined) return loading; + const loadStarted = new Map(generations); + loading = store.run(registry, { _tag: "load" }).then((result) => { + const loadedDocument = AsyncResult.isSuccess(result) ? result.value : EMPTY_DOCUMENT; + // An environment removed while the read was in flight must not come back from it. + const removed = (environmentId: EnvironmentId) => + (generations.get(environmentId) ?? 0) !== (loadStarted.get(environmentId) ?? 0); + const current = registry.get(documentAtom) ?? { + entries: loadedDocument.entries.filter((entry) => !removed(entry.environmentId)), + operableEnvironmentIds: loadedDocument.operableEnvironmentIds.filter((id) => !removed(id)), + }; registry.set(documentAtom, current); return current; }); @@ -187,8 +196,6 @@ export function createThreadTriageOutbox(input: { /** One owner per thread at a time: a replay, an Undo, or a replacing action. */ const claimed = new Set(); - /** Bumped when an environment is removed so in-flight work does not re-add its entries. */ - const generations = new Map(); const clearLocal = (registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId) => { generations.set(environmentId, (generations.get(environmentId) ?? 0) + 1); const document = registry.get(documentAtom); From b930245e7037cccb07904432260547e55c6c7393 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 15:32:37 +0000 Subject: [PATCH 04/44] test(web): update thread action fixtures for offline triage The thread action menu mock now provides readThreadTriageScope from the same live grant, and a successful settle in the Undo tests returns its sequence like the real command. A new case checks that Undo of a settle still waiting to sync only cancels it, without re-pinning or re-snoozing. Co-Authored-By: Claude Opus 5.5 --- .../web/src/hooks/useThreadActionMenu.test.ts | 6 +++++- .../src/hooks/useThreadActions.undo.test.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts index 899cf38ee51bb..1dd5cff42a03c 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -65,7 +65,11 @@ vi.mock("../state/entities", () => ({ useProjects: () => [{ id: "project", environmentId: "secondary" }], })); vi.mock("../state/environments", () => ({ usePrimaryEnvironmentId: () => "primary" })); -vi.mock("../state/threads", () => ({ threadEnvironment: { updateMetadata: "metadata" } })); +vi.mock("../state/threads", () => ({ + // No remembered offline permission here, so triage follows the live grant. + readThreadTriageScope: (environmentId: string) => state.granted.has(environmentId), + threadEnvironment: { updateMetadata: "metadata" }, +})); vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => async () => { recordEffect("metadata"); diff --git a/apps/web/src/hooks/useThreadActions.undo.test.ts b/apps/web/src/hooks/useThreadActions.undo.test.ts index b31f657458e38..1ee34bb85a405 100644 --- a/apps/web/src/hooks/useThreadActions.undo.test.ts +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -96,6 +96,7 @@ beforeEach(() => { for (const command of Object.values(commands)) { command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); } + commands.settle.mockResolvedValue({ _tag: "Success", value: { sequence: 1 } }); router.navigate.mockClear(); router.state.matches[0]!.params = {}; threadShell.pinnedAt = null; @@ -235,6 +236,24 @@ describe("settle and snooze Undo", () => { }); }); + // A settle queued offline never reached the server, so cancelling it restores pin and snooze. + it("only cancels a settle that is still waiting to sync", async () => { + threadShell.pinnedAt = "2026-01-01T00:00:00.000Z"; + threadShell.snoozedUntil = "2030-01-01T09:00:00.000Z"; + commands.settle.mockResolvedValue({ + _tag: "Success", + value: { sequence: 1, pendingSync: true }, + }); + await useThreadActions().settleThread(target); + await currentUndo()(); + expect(commands.unsettle).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId, reason: "user" }, + }); + expect(commands.pin).not.toHaveBeenCalled(); + expect(commands.snooze).not.toHaveBeenCalled(); + }); + it("stops settle restoration if restoring the pin fails", async () => { threadShell.pinnedAt = "2026-01-01T00:00:00.000Z"; threadShell.snoozedUntil = "2030-01-01T09:00:00.000Z"; From 5bdf5fb98280bbdf0fa689d6713e907b82ac177b Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 15:43:02 +0000 Subject: [PATCH 05/44] fix: decide queued settle Undo when it runs, and keep removed grants removed - Undo keeps the full restoration plan from the settle. Cancelling a settle still waiting to sync returns a cancelledPendingSync marker and the plan stops there; if the settle already reached the server, Undo un-settles there and restores pin and snooze as usual. - Removing an environment records the session grant current at that moment and ignores it afterwards, so a retained session cannot write the grant back. A new session, as after adding the environment again, is remembered. - Grant and queued-change writes check, inside the store's lock, that the environment was not removed since they were issued, and removal listeners run inside the same lock. Co-Authored-By: Claude Opus 5.5 --- .../home/useThreadListActions.test.ts | 23 ++++- .../src/features/home/useThreadListActions.ts | 18 ++-- apps/web/src/hooks/useThreadActions.ts | 7 +- .../src/hooks/useThreadActions.undo.test.ts | 49 ++++++---- .../src/platform/threadTriageOutboxStore.ts | 94 ++++++++++++------- packages/client-runtime/src/state/session.ts | 19 +++- .../src/state/threadCommands.test.ts | 80 +++++++++++++++- .../src/state/threadCommands.ts | 8 +- .../src/state/threadTriageOutbox.ts | 73 +++++++++----- .../client-runtime/src/state/threadUndo.ts | 13 +++ 10 files changed, 286 insertions(+), 98 deletions(-) diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 788c09970fceb..8f797881bd45d 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -19,6 +19,7 @@ const state = vi.hoisted(() => ({ archivedRefreshes: [] as string[], scopes: new Map>(), offlineTriage: new Set(), + successValues: new Map(), shells: [] as EnvironmentThreadShell[], requests: [] as { action: string; @@ -149,7 +150,7 @@ vi.mock("../../state/threads", () => ({ if (action === state.failedAction) return AsyncResult.failure(Cause.fail(new Error("Server rejected action"))); state.afterRequest?.(); - return AsyncResult.success(undefined); + return AsyncResult.success(state.successValues.get(action)); }, ]), ), @@ -190,6 +191,7 @@ beforeEach(() => { state.pendingOrder = null; state.dropBusy = false; state.offlineTriage = new Set(); + state.successValues = new Map(); state.scopes = new Map([ [primaryEnvironmentId, new Set([AuthOrchestrationOperateScope])], [otherEnvironmentId, new Set()], @@ -374,6 +376,25 @@ describe("thread action undo", () => { expect(readThreadUndoNotice()).toBeNull(); }); + // Whether the queued settle reached the server is known only when Undo runs. + it.each([ + ["cancels it before it syncs", { sequence: 0, cancelledPendingSync: true }, []], + ["finds it delivered", { sequence: 3 }, ["pin", "snooze"]], + ])("restores a queued settle only as far as needed when Undo %s", async (_, undone, restored) => { + state.successValues.set("settle", { sequence: 1, pendingSync: true }); + state.successValues.set("unsettle", undone); + const thread = makeThread({ + pinnedAt: "2026-09-02T00:00:00.000Z", + pinOrderKey: "m", + snoozedUntil: "2099-01-01T09:00:00.000Z", + }); + expect(await useThreadListActions().settleThread(thread)).toBe(true); + expect(readThreadUndoNotice()?.action).toBe("Settled"); + await readThreadUndoNotice()!.undo(); + expect(state.requests.map(({ action }) => action)).toEqual(["settle", "unsettle", ...restored]); + expect(readThreadUndoNotice()).toBeNull(); + }); + // regression: #21401 restore archive and thread action undo it("wakes a snoozed thread through the undo action", async () => { const thread = makeThread(); diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index cacbe779fcc5a..a923dadc48ea8 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -141,6 +141,7 @@ function useThreadActionExecutor( action: ThreadListAction, thread: EnvironmentThreadShell, onStarted?: () => void, + onSucceeded?: (value: unknown) => void, ) => { const triage = action === "settle" || action === "unsettle"; if (!checkThreadOperationPermission(thread, actionFailureTitle(action), triage)) return false; @@ -205,6 +206,7 @@ function useThreadActionExecutor( if (action === "archive" || action === "unarchive" || action === "delete") { refreshArchivedThreadsForEnvironment(thread.environmentId); } + onSucceeded?.(result.value); onCompleted?.(action, thread); return true; })(); @@ -279,13 +281,19 @@ export function useThreadListActions(): { action, claim, undo: async () => { + // Cancelling a settle still waiting to sync leaves nothing on the server to restore. + let cancelledQueued = false; try { await ThreadUndo.executeThreadUndo( plan, async (step) => { switch (step.action) { case "unsettle": - return unsettleThread(thread); + return ( + (await executeAction("unsettle", thread, undefined, (value) => { + cancelledQueued = ThreadUndo.cancelledPendingSync(value); + })) === true + ); case "unsnooze": return unsnoozeThread(thread); case "pin": @@ -310,7 +318,7 @@ export function useThreadListActions(): { } } }, - (result) => result, + (result) => result && !cancelledQueued, ); } catch (error) { Dialog.alert( @@ -330,11 +338,7 @@ export function useThreadListActions(): { claim = ThreadUndo.begin("settle", key); }); if (claim) { - // A queued settle never reached the server, so cancelling it restores everything. - const queued = appAtomRegistry - .get(threadEnvironment.pendingSyncAtom(thread.environmentId)) - .has(thread.id); - if (succeeded) offerUndo(thread, "Settled", claim, queued ? [{ action: "unsettle" }] : plan); + if (succeeded) offerUndo(thread, "Settled", claim, plan); else claim.finish(); } return succeeded; diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 569defe50f89e..d480008faaa94 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -746,9 +746,7 @@ export function useThreadActions() { showThreadUndoNotice({ action: "Settled", claim: action, - // A queued settle never reached the server, so cancelling it restores everything. - undo: () => - runUndoPlan(target, result.value.pendingSync ? [{ action: "unsettle" }] : undoPlan), + undo: () => runUndoPlan(target, undoPlan), failureTitle: "Failed to undo settle", }); return result; @@ -887,7 +885,8 @@ export function useThreadActions() { }); } }, - (result) => result._tag === "Success", + // Cancelling a settle still waiting to sync leaves nothing on the server to restore. + (result) => result._tag === "Success" && !ThreadUndo.cancelledPendingSync(result.value), ); const confirmAndDeleteThread = async (target: ScopedThreadRef) => { diff --git a/apps/web/src/hooks/useThreadActions.undo.test.ts b/apps/web/src/hooks/useThreadActions.undo.test.ts index 1ee34bb85a405..d81518abfd041 100644 --- a/apps/web/src/hooks/useThreadActions.undo.test.ts +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -236,23 +236,38 @@ describe("settle and snooze Undo", () => { }); }); - // A settle queued offline never reached the server, so cancelling it restores pin and snooze. - it("only cancels a settle that is still waiting to sync", async () => { - threadShell.pinnedAt = "2026-01-01T00:00:00.000Z"; - threadShell.snoozedUntil = "2030-01-01T09:00:00.000Z"; - commands.settle.mockResolvedValue({ - _tag: "Success", - value: { sequence: 1, pendingSync: true }, - }); - await useThreadActions().settleThread(target); - await currentUndo()(); - expect(commands.unsettle).toHaveBeenCalledExactlyOnceWith({ - environmentId: target.environmentId, - input: { threadId: target.threadId, reason: "user" }, - }); - expect(commands.pin).not.toHaveBeenCalled(); - expect(commands.snooze).not.toHaveBeenCalled(); - }); + // Whether the queued settle reached the server is known only when Undo runs. + it.each([ + ["cancels it before it syncs", { sequence: 0, cancelledPendingSync: true }, 0], + ["finds it delivered", { sequence: 3 }, 1], + ] as const)( + "restores a queued settle only as far as needed when Undo %s", + async (_, undone, restorations) => { + const snoozedUntil = "2030-01-01T09:00:00.000Z"; + threadShell.pinnedAt = "2026-01-01T00:00:00.000Z"; + threadShell.snoozedUntil = snoozedUntil; + commands.settle.mockResolvedValue({ + _tag: "Success", + value: { sequence: 1, pendingSync: true }, + }); + commands.unsettle.mockResolvedValue({ _tag: "Success", value: undone }); + await useThreadActions().settleThread(target); + expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Settled", count: 1 }); + await currentUndo()(); + expect(commands.unsettle).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId, reason: "user" }, + }); + expect(commands.pin).toHaveBeenCalledTimes(restorations); + expect(commands.snooze).toHaveBeenCalledTimes(restorations); + if (restorations > 0) { + expect(commands.snooze).toHaveBeenCalledWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId, snoozedUntil }, + }); + } + }, + ); it("stops settle restoration if restoring the pin fails", async () => { threadShell.pinnedAt = "2026-01-01T00:00:00.000Z"; diff --git a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts index c32b16f59008f..e9caa16349fab 100644 --- a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts +++ b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts @@ -50,8 +50,14 @@ export const sameThreadTriage = (a: ThreadTriageKey, b: ThreadTriageKey) => */ export interface ThreadTriageOutboxStoreShape { readonly load: Effect.Effect; - /** Replaces any entry for the same thread. */ - readonly put: (entry: PendingThreadTriage) => Effect.Effect; + /** + * Replaces any entry for the same thread. `stillCurrent` runs inside the store's lock, after + * any earlier removal, so a write for a removed environment is skipped. + */ + readonly put: ( + entry: PendingThreadTriage, + stillCurrent?: () => boolean, + ) => Effect.Effect; /** Only removes the entry if it still has this command id. */ readonly remove: ( entry: ThreadTriageKey & Pick, @@ -59,11 +65,15 @@ export interface ThreadTriageOutboxStoreShape { readonly setOperable: ( environmentId: EnvironmentId, operable: boolean, + stillCurrent?: () => boolean, ) => Effect.Effect; readonly clearEnvironment: ( environmentId: EnvironmentId, ) => Effect.Effect; - /** Lets in-memory copies drop a removed environment's entries; returns the unsubscribe. */ + /** + * Lets in-memory copies drop a removed environment's entries; returns the unsubscribe. + * Listeners run inside the store's lock, before any later write. + */ readonly subscribeCleared: (listener: (environmentId: EnvironmentId) => void) => () => void; } @@ -102,17 +112,23 @@ export function makeThreadTriageOutboxStore(storage: { ), ), ); + /** A null transform skips the write. `after` still runs inside the lock. */ const update = ( - transform: (document: ThreadTriageOutboxDocument) => ThreadTriageOutboxDocument, + transform: (document: ThreadTriageOutboxDocument) => ThreadTriageOutboxDocument | null, + after: Effect.Effect = Effect.void, ) => lock.withPermits(1)( load.pipe( - Effect.flatMap((document) => - encodeStored(transform(document)).pipe( - Effect.mapError((cause) => new ThreadTriageOutboxStorageError({ cause })), - ), - ), - Effect.flatMap(storage.write), + Effect.flatMap((document) => { + const next = transform(document); + return next === null + ? Effect.void + : encodeStored(next).pipe( + Effect.mapError((cause) => new ThreadTriageOutboxStorageError({ cause })), + Effect.flatMap(storage.write), + ); + }), + Effect.ensuring(after), ), ); const updateEntries = ( @@ -120,38 +136,48 @@ export function makeThreadTriageOutboxStore(storage: { ) => update((document) => ({ ...document, entries: transform(document.entries) })); return { load: lock.withPermits(1)(load), - put: (entry) => - updateEntries((entries) => [ - ...entries.filter((current) => !sameThreadTriage(current, entry)), - entry, - ]), + put: (entry, stillCurrent) => + update((document) => + stillCurrent?.() === false + ? null + : { + ...document, + entries: [ + ...document.entries.filter((current) => !sameThreadTriage(current, entry)), + entry, + ], + }, + ), remove: (entry) => updateEntries((entries) => entries.filter( (current) => !sameThreadTriage(current, entry) || current.commandId !== entry.commandId, ), ), - setOperable: (environmentId, operable) => - update((document) => ({ - ...document, - operableEnvironmentIds: [ - ...document.operableEnvironmentIds.filter((id) => id !== environmentId), - ...(operable ? [environmentId] : []), - ], - })), + setOperable: (environmentId, operable, stillCurrent) => + update((document) => + stillCurrent?.() === false + ? null + : { + ...document, + operableEnvironmentIds: [ + ...document.operableEnvironmentIds.filter((id) => id !== environmentId), + ...(operable ? [environmentId] : []), + ], + }, + ), clearEnvironment: (environmentId) => - update((document) => ({ - entries: document.entries.filter((entry) => entry.environmentId !== environmentId), - operableEnvironmentIds: document.operableEnvironmentIds.filter( - (id) => id !== environmentId, - ), - })).pipe( + update( + (document) => ({ + entries: document.entries.filter((entry) => entry.environmentId !== environmentId), + operableEnvironmentIds: document.operableEnvironmentIds.filter( + (id) => id !== environmentId, + ), + }), // The environment is gone even if storage failed, so memory must not replay it. - Effect.ensuring( - Effect.sync(() => { - for (const listener of clearedListeners) listener(environmentId); - }), - ), + Effect.sync(() => { + for (const listener of clearedListeners) listener(environmentId); + }), ), subscribeCleared: (listener) => { clearedListeners.add(listener); diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 273dc9a2b6a5e..2ef056740f101 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -161,13 +161,22 @@ function makeEnvironmentSessionAtoms( ).pipe(Atom.withLabel(`environment-session-state-value:${environmentId}`)), ); - /** Null until a session loads, so offline callers can tell unknown from denied. */ + /** + * Null until a session loads, so offline callers can tell unknown from denied. Each loaded + * session gets its own object, so callers can tell a new session from a retained one. + */ + const operateGrants = new WeakMap(); const operateScopeAtom = Atom.family((environmentId: EnvironmentId) => - Atom.make((get): boolean | null => { + Atom.make((get): { readonly granted: boolean } | null => { const result = get(sessionStateAtom(environmentId)); - return AsyncResult.isSuccess(result) - ? sessionGrantsScope(result.value, AuthOrchestrationOperateScope) - : null; + if (!AsyncResult.isSuccess(result)) return null; + const existing = operateGrants.get(result.value); + if (existing !== undefined) return existing; + const grant = { + granted: sessionGrantsScope(result.value, AuthOrchestrationOperateScope), + }; + operateGrants.set(result.value, grant); + return grant; }).pipe(Atom.withLabel(`environment-session-operate-scope:${environmentId}`)), ); diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index e0513ded3c854..e571b55191029 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -31,6 +31,7 @@ import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import type { RpcSession } from "../rpc/session.ts"; import type { EnvironmentShellStatus } from "./shell.ts"; import { createThreadEnvironmentAtoms } from "./threadCommands.ts"; +import * as ThreadUndo from "./threadUndo.ts"; import { makeThreadTriageOutboxStore, ThreadTriageOutboxStorageError, @@ -178,7 +179,9 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio Atom.make(options?.connected === false ? "cached" : "live"), ); const operateScopeAtom = Atom.family((_environmentId: EnvironmentId) => - Atom.make(options?.operateScope === undefined ? true : options.operateScope), + Atom.make<{ readonly granted: boolean } | null>( + options?.operateScope === null ? null : { granted: options?.operateScope ?? true }, + ), ); const serverConfigAtom = Atom.family((_environmentId: EnvironmentId) => Atom.make({ @@ -687,7 +690,7 @@ describe("settle and snooze while the environment is offline", () => { input: { threadId: THREAD_ID, reason: "user" }, }), ); - expect(result._tag).toBe("Success"); + expect(result._tag === "Success" && ThreadUndo.cancelledPendingSync(result.value)).toBe(true); expect(h.registry.get(pending).size).toBe(0); expect(stored(h.storage)).toEqual([]); expect(h.registry.get(h.visibleAtom)).toBe(SNAPSHOT); @@ -851,7 +854,7 @@ describe("settle and snooze while the environment is offline", () => { yield* waitFor(coldOffline.registry, permitted, (value) => value); // A session that loads without the scope wins and is remembered for the next restart. - coldOffline.registry.set(coldOffline.operateScopeAtom(ENVIRONMENT_ID), false); + coldOffline.registry.set(coldOffline.operateScopeAtom(ENVIRONMENT_ID), { granted: false }); expect(coldOffline.registry.get(permitted)).toBe(false); yield* Queue.take(writes); const restarted = yield* makeHarness({ connected: false, storage, operateScope: null }); @@ -913,10 +916,11 @@ describe("settle and snooze while the environment is offline", () => { expect(stored(storage)).toHaveLength(1); // The removal lands after the read but before the loaded document is published. + // The session from before the removal is still loaded and still grants the scope. const h = yield* makeHarness({ connected: false, storage, - operateScope: null, + operateScope: true, wrapStore: (store) => ({ ...store, load: store.load.pipe(Effect.tap(() => store.clearEnvironment(ENVIRONMENT_ID))), @@ -941,6 +945,74 @@ describe("settle and snooze while the environment is offline", () => { expect([...h.registry.get(pending)]).toEqual([laterThreadId]); expect(stored(storage)).toMatchObject([{ threadId: laterThreadId }]); expect(h.registry.get(permitted)).toBe(false); + expect(JSON.parse(storage.value!).operableEnvironmentIds).toEqual([]); + }), + ); + + it.effect("does not let a retained session or an in-flight write restore a removed grant", () => + Effect.gen(function* () { + const writes = yield* Queue.unbounded(); + const storage = { value: null as string | null, writes }; + let removeBeforeWrite = true; + const h = yield* makeHarness({ + connected: false, + storage, + operateScope: true, + // The removal lands while the grant's write waits for the store. + wrapStore: (store) => ({ + ...store, + setOperable: (environmentId, operable, stillCurrent) => + (removeBeforeWrite + ? Effect.sync(() => { + removeBeforeWrite = false; + }).pipe(Effect.andThen(store.clearEnvironment(environmentId))) + : Effect.void + ).pipe(Effect.andThen(store.setOperable(environmentId, operable, stillCurrent))), + }), + }); + const permitted = h.commands.offlineTriagePermittedAtom(ENVIRONMENT_ID); + h.registry.mount(permitted); + expect(JSON.parse(yield* Queue.take(writes)).operableEnvironmentIds).toEqual([]); + expect(h.registry.get(permitted)).toBe(false); + // The store runs writes in order, so this one follows the skipped grant. + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + expect(JSON.parse(yield* Queue.take(writes))).toMatchObject({ + entries: [{ threadId: THREAD_ID }], + operableEnvironmentIds: [], + }); + + // Adding the environment again brings a new session, which is remembered. + h.registry.set(h.operateScopeAtom(ENVIRONMENT_ID), { granted: true }); + expect(h.registry.get(permitted)).toBe(true); + expect(JSON.parse(yield* Queue.take(writes)).operableEnvironmentIds).toEqual([ + ENVIRONMENT_ID, + ]); + }), + ); + + it.effect("Undo after a queued settle was delivered un-settles on the server", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false }); + const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); + h.registry.mount(pending); + const queued = yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + expect(queued._tag === "Success" && queued.value.pendingSync).toBe(true); + yield* h.reconnect(); + const replay = yield* Queue.take(h.requests); + yield* Deferred.succeed(replay.reply, { sequence: 2 }); + yield* waitFor(h.registry, pending, (threads) => threads.size === 0); + + const undo = h.commands.unsettle.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, reason: "user" }, + }); + const request = yield* Queue.take(h.requests); + expect(request.command.type).toBe("thread.unsettle"); + yield* Deferred.succeed(request.reply, { sequence: 3 }); + const result = yield* Effect.promise(() => undo); + expect(result._tag === "Success" && ThreadUndo.cancelledPendingSync(result.value)).toBe( + false, + ); }), ); }); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index d0bdabbc92eeb..75e6c7bf6f8b2 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -196,7 +196,9 @@ export function createThreadEnvironmentAtoms( /** Offline settle and snooze replay once this reports a synchronized shell. */ readonly shellStatusAtom?: (environmentId: EnvironmentId) => Atom.Atom; /** Whether the loaded session can change threads; null until it loads. */ - readonly operateScopeAtom?: (environmentId: EnvironmentId) => Atom.Atom; + readonly operateScopeAtom?: ( + environmentId: EnvironmentId, + ) => Atom.Atom<{ readonly granted: boolean } | null>; /** Cached config; offline changes queue only for servers that can check them. */ readonly serverConfigAtom?: (environmentId: EnvironmentId) => Atom.Atom; } = {}, @@ -534,7 +536,7 @@ export function createThreadEnvironmentAtoms( unsettledAt: thread.settledOverride === "active" ? (thread.unsettledAt ?? null) : now, })), "settle", - { sequence: 0 }, + { sequence: 0, cancelledPendingSync: true as const }, ), snooze: triage.queueWhenOffline(snooze, (input) => ({ type: "snooze" as const, @@ -547,7 +549,7 @@ export function createThreadEnvironmentAtoms( snoozedAt: null, })), "snooze", - { sequence: 0 }, + { sequence: 0, cancelledPendingSync: true as const }, ), setAutoSettle: optimistic.wrap(commands.setAutoSettle, (thread, input, now) => ({ ...thread, diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index 96025740f1d43..a3da7734dc4ec 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -41,12 +41,17 @@ type Command = AtomCommand< type StoreOperation = | { readonly _tag: "load" } - | { readonly _tag: "put"; readonly entry: Omit } + | { + readonly _tag: "put"; + readonly entry: Omit; + readonly stillCurrent: () => boolean; + } | { readonly _tag: "remove"; readonly entry: PendingThreadTriage } | { readonly _tag: "setOperable"; readonly environmentId: EnvironmentId; readonly operable: boolean; + readonly stillCurrent: () => boolean; }; const EMPTY_DOCUMENT: ThreadTriageOutboxDocument = { entries: [], operableEnvironmentIds: [] }; @@ -87,8 +92,13 @@ export function createThreadTriageOutbox(input: { ) => Atom.Atom; /** Replay waits for a synchronized shell; without one, nothing replays. */ readonly shellStatusAtom?: (environmentId: EnvironmentId) => Atom.Atom; - /** Whether the loaded session can change threads; null until it loads. */ - readonly operateScopeAtom?: (environmentId: EnvironmentId) => Atom.Atom; + /** + * Whether the loaded session can change threads; null until it loads. A new object means a + * newly loaded session, which is how a removed environment's old grant is told apart. + */ + readonly operateScopeAtom?: ( + environmentId: EnvironmentId, + ) => Atom.Atom<{ readonly granted: boolean } | null>; /** Cached config; only servers that check the snapshot sequence get queued changes. */ readonly serverConfigAtom?: (environmentId: EnvironmentId) => Atom.Atom; readonly applySettle: ( @@ -129,7 +139,7 @@ export function createThreadTriageOutbox(input: { ...operation.entry, commandId: CommandId.make(yield* crypto.randomUUIDv4().pipe(Effect.orDie)), }; - yield* outbox.put(entry).pipe( + yield* outbox.put(entry, operation.stillCurrent).pipe( Effect.tapError((error) => Effect.logWarning("Could not save an offline thread change.", { cause: String(error.cause), @@ -148,13 +158,15 @@ export function createThreadTriageOutbox(input: { ); return EMPTY_DOCUMENT; case "setOperable": - yield* outbox.setOperable(operation.environmentId, operation.operable).pipe( - Effect.catch((error) => - Effect.logWarning("Could not remember thread permissions.", { - cause: String(error.cause), - }), - ), - ); + yield* outbox + .setOperable(operation.environmentId, operation.operable, operation.stillCurrent) + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not remember thread permissions.", { + cause: String(error.cause), + }), + ), + ); return EMPTY_DOCUMENT; } }), @@ -162,6 +174,8 @@ export function createThreadTriageOutbox(input: { /** Bumped when an environment is removed so in-flight work does not re-add its entries. */ const generations = new Map(); + /** The session grant current when each environment was removed. */ + const removedGrants = new Map(); const documentAtom = Atom.make(null).pipe(Atom.keepAlive); let loading: Promise | undefined; const ensureLoaded = (registry: AtomRegistry.AtomRegistry) => { @@ -198,6 +212,10 @@ export function createThreadTriageOutbox(input: { const claimed = new Set(); const clearLocal = (registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId) => { generations.set(environmentId, (generations.get(environmentId) ?? 0) + 1); + removedGrants.set( + environmentId, + input.operateScopeAtom ? registry.get(input.operateScopeAtom(environmentId)) : null, + ); const document = registry.get(documentAtom); if (document === null) return; registry.set(documentAtom, { @@ -364,13 +382,11 @@ export function createThreadTriageOutbox(input: { queuedAt: DateTime.nowUnsafe(), snapshotSequence: snapshot.snapshotSequence, }, + stillCurrent: current, }); - if (!AsyncResult.isSuccess(queued)) return result; + // A removal before the write skipped it; one after it already deleted the entry. + if (!AsyncResult.isSuccess(queued) || !current()) return result; const entry = queued.value.entries[0]!; - if (!current()) { - await store.run(registry, { _tag: "remove", entry }); - return result; - } setLocal(registry, (entries) => [ ...entries.filter((existing) => !sameThreadTriage(existing, entry)), entry, @@ -384,13 +400,13 @@ export function createThreadTriageOutbox(input: { } /** Undo before sync cancels the queued action instead of asking the server. */ - function cancelQueued( + function cancelQueued( command: AtomCommand<{ readonly environmentId: EnvironmentId; readonly input: I }, A, CE>, cancels: PendingThreadTriage["action"]["type"], - cancelledValue: A, + cancelledValue: B, ): AtomCommand< { readonly environmentId: EnvironmentId; readonly input: I }, - A, + A | B, CE | ThreadTriageOutboxStorageError | E > { return { @@ -434,18 +450,29 @@ export function createThreadTriageOutbox(input: { const document = get(loadedDocumentAtom); const remembered = document?.operableEnvironmentIds.includes(environmentId) ?? false; if (known === null) return remembered; - if (document !== null && known !== remembered) { + // The session kept from before a removal must not restore its grant; a new one may. + if (removedGrants.has(environmentId)) { + if (removedGrants.get(environmentId) === known) return remembered; + removedGrants.delete(environmentId); + } + const generation = generations.get(environmentId) ?? 0; + if (document !== null && known.granted !== remembered) { const registry = get.registry; registry.set(documentAtom, { ...document, operableEnvironmentIds: [ ...document.operableEnvironmentIds.filter((id) => id !== environmentId), - ...(known ? [environmentId] : []), + ...(known.granted ? [environmentId] : []), ], }); - void store.run(registry, { _tag: "setOperable", environmentId, operable: known }); + void store.run(registry, { + _tag: "setOperable", + environmentId, + operable: known.granted, + stillCurrent: () => (generations.get(environmentId) ?? 0) === generation, + }); } - return known; + return known.granted; }), ); diff --git a/packages/client-runtime/src/state/threadUndo.ts b/packages/client-runtime/src/state/threadUndo.ts index c9a8ac402bee5..7552afa9e81c9 100644 --- a/packages/client-runtime/src/state/threadUndo.ts +++ b/packages/client-runtime/src/state/threadUndo.ts @@ -65,6 +65,19 @@ export function planThreadUndo( return [{ action: "unsettle" }, ...(thread?.pinnedAt != null ? [pin] : []), ...snooze]; } +/** + * True when an Undo step cancelled a settle or snooze still waiting to sync. The server never + * saw the change, so the rest of the plan (re-pin, re-snooze) has nothing to restore. + */ +export function cancelledPendingSync(value: unknown): boolean { + return ( + typeof value === "object" && + value !== null && + "cancelledPendingSync" in value && + value.cancelledPendingSync === true + ); +} + export async function executeThreadUndo( plan: ThreadUndoPlan, execute: (step: ThreadUndoStep) => Promise, From f806de2702b792107cdc96648fabee717d802f6e Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 16:00:15 +0000 Subject: [PATCH 06/44] fix: let web triage pass the offline grant, queue the acted-on snapshot, and bind grants to registration - useOrchestrationCommand takes an optional permission check. Only settle, snooze and their undo pass readThreadTriageScope, so they work with the remembered offline grant; every other web mutation still needs the live grant, and the check still runs on every call. - An offline settle or snooze records the shell snapshot from the moment it was invoked, before any await, so a newer shell cannot vouch for it. - The remembered offline grant is tied to the environment being registered. A removed environment's late or retained session grants nothing and writes nothing; registering it again works. This replaces the session tombstone, so the operate scope atom is a plain boolean again. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/state/threads.ts | 8 +++ .../components/chat/useThreadLifecycle.tsx | 16 +++-- .../useThreadActions.permissions.test.ts | 22 +++++- apps/web/src/hooks/useThreadActions.ts | 34 +++++---- apps/web/src/state/threads.ts | 8 +++ .../state/use-orchestration-command.test.ts | 13 ++++ .../src/state/use-orchestration-command.ts | 8 ++- packages/client-runtime/src/state/session.ts | 19 ++--- .../src/state/threadCommands.test.ts | 69 +++++++++++++++++-- .../src/state/threadCommands.ts | 6 +- .../src/state/threadTriageOutbox.ts | 42 +++++------ 11 files changed, 178 insertions(+), 67 deletions(-) diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 517f2d28ff103..612131ce778e1 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -29,6 +29,13 @@ export const olderSettledThreads = createOlderSettledThreadAtoms( connectionAtomRuntime, environmentShell.stateAtom, ); +// Null until the catalog loads; a removed environment is false. +const environmentRegisteredAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const catalog = get(environmentCatalog.catalogValueAtom); + return catalog.isReady ? catalog.entries.has(environmentId) : null; + }), +); export const threadEnvironment = createThreadEnvironmentAtoms( connectionAtomRuntime, threadFamilies.snapshotAtom, @@ -36,6 +43,7 @@ export const threadEnvironment = createThreadEnvironmentAtoms( shellStatusAtom: environmentShell.statusAtom, operateScopeAtom: environmentSession.operateScopeAtom, serverConfigAtom: serverEnvironment.configValueAtom, + registeredAtom: environmentRegisteredAtom, }, ); export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index cb8a1a64bad4d..c445657c2c33f 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -222,9 +222,11 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { }); const activeThreadSettled = supportsSettlement && activeThreadShell?.settledOverride === "settled"; - const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, { - reportFailure: false, - }); + const unsettleThreadMutation = useOrchestrationCommand( + threadEnvironment.unsettle, + { reportFailure: false }, + readThreadTriageScope, + ); const [unsettlingThreadKey, setUnsettlingThreadKey] = useState(null); const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey; const handleUnsettleActiveThread = async () => { @@ -249,9 +251,11 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); }; const handleUnsettleActiveThreadStable = useStableCallback(handleUnsettleActiveThread); - const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, { - reportFailure: false, - }); + const unsnoozeThreadMutation = useOrchestrationCommand( + threadEnvironment.unsnooze, + { reportFailure: false }, + readThreadTriageScope, + ); const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState(null); const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey; const handleUnsnoozeActiveThread = async () => { diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index a1b39cdf47bd8..2c392c5d2b0af 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -20,6 +20,7 @@ function deferred() { const state = vi.hoisted(() => ({ scopes: new Map>(), + offlineTriage: new Set(), threads: [] as { environmentId: EnvironmentId; id: ThreadId; @@ -57,7 +58,11 @@ vi.mock("../state/use-atom-command", () => ({ state.requests.push({ action, ...request }); const scope = action === "removeWorktree" ? AuthSourceControlWriteScope : AuthOrchestrationOperateScope; - if (!state.scopes.get(request.environmentId)?.has(scope)) { + // A remembered offline grant lets the command queue settle and snooze locally. + if ( + !state.scopes.get(request.environmentId)?.has(scope) && + !state.offlineTriage.has(request.environmentId) + ) { return AsyncResult.failure(Cause.fail(new Error("Server denied the request"))); } state.afterRequest?.(action); @@ -75,6 +80,9 @@ vi.mock("../state/use-atom-query-runner", () => ({ }), })); vi.mock("../state/threads", () => ({ + readThreadTriageScope: (environmentId: string) => + state.scopes.get(environmentId)?.has(AuthOrchestrationOperateScope) === true || + state.offlineTriage.has(environmentId), threadEnvironment: Object.fromEntries( [ "archive", @@ -223,6 +231,7 @@ beforeEach(() => { latestTurn: null, }, ]; + state.offlineTriage = new Set(); state.requests = []; state.localEffects = []; state.confirm.mockReset().mockResolvedValue(true); @@ -260,6 +269,17 @@ describe("thread action permissions", () => { }, ); + it.each(operations)( + "$name follows the remembered offline grant only for settle and snooze", + async ({ name, run }) => { + state.offlineTriage.add(secondary); + const result = await run(useThreadActions(), target); + const triage = ["settle", "unsettle", "snooze", "unsnooze"].includes(name); + expect(result._tag).toBe(triage ? "Success" : "Failure"); + expect(state.requests.map(({ action }) => action)).toEqual(triage ? [name] : []); + }, + ); + it.each(["confirmAndDeleteThread", "confirmAndUnpinThread"] as const)( "%s blocks a forbidden confirmation", async (action) => { diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index d480008faaa94..e0e82f22c0ed3 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -29,7 +29,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentServerConfigsAtom } from "../state/server"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { orchestrationEnvironment } from "../state/orchestration"; -import { threadEnvironment } from "../state/threads"; +import { readThreadTriageScope, threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; @@ -256,12 +256,16 @@ export function useThreadActions() { const deleteThreadMutation = useOrchestrationCommand(threadEnvironment.delete, { reportFailure: false, }); - const settleThreadMutation = useOrchestrationCommand(threadEnvironment.settle, { - reportFailure: false, - }); - const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, { - reportFailure: false, - }); + const settleThreadMutation = useOrchestrationCommand( + threadEnvironment.settle, + { reportFailure: false }, + readThreadTriageScope, + ); + const unsettleThreadMutation = useOrchestrationCommand( + threadEnvironment.unsettle, + { reportFailure: false }, + readThreadTriageScope, + ); const pinThreadMutation = useOrchestrationCommand(threadEnvironment.pin, { reportFailure: false, }); @@ -277,12 +281,16 @@ export function useThreadActions() { const reorderActiveThreadMutation = useOrchestrationCommand(threadEnvironment.reorderActive, { reportFailure: false, }); - const snoozeThreadMutation = useOrchestrationCommand(threadEnvironment.snooze, { - reportFailure: false, - }); - const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, { - reportFailure: false, - }); + const snoozeThreadMutation = useOrchestrationCommand( + threadEnvironment.snooze, + { reportFailure: false }, + readThreadTriageScope, + ); + const unsnoozeThreadMutation = useOrchestrationCommand( + threadEnvironment.unsnooze, + { reportFailure: false }, + readThreadTriageScope, + ); const markThreadUnread = useMarkThreadUnread(); const stopThreadSession = useOrchestrationCommand(threadEnvironment.stopSession); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index c58f3c9386094..ce5316d181a7e 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -36,6 +36,13 @@ export const olderSettledThreads = createOlderSettledThreadAtoms( connectionAtomRuntime, environmentShell.stateAtom, ); +// Null until the catalog loads; a removed environment is false. +const environmentRegisteredAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const catalog = get(environmentCatalog.catalogValueAtom); + return catalog.isReady ? catalog.entries.has(environmentId) : null; + }), +); export const threadEnvironment = createThreadEnvironmentAtoms( connectionAtomRuntime, threadFamilies.snapshotAtom, @@ -43,6 +50,7 @@ export const threadEnvironment = createThreadEnvironmentAtoms( shellStatusAtom: environmentShell.statusAtom, operateScopeAtom: environmentSession.operateScopeAtom, serverConfigAtom: serverEnvironment.configValueAtom, + registeredAtom: environmentRegisteredAtom, }, ); /** Settle, snooze and their undo can queue offline where the last session could operate. */ diff --git a/apps/web/src/state/use-orchestration-command.test.ts b/apps/web/src/state/use-orchestration-command.test.ts index c0ddca203f081..1087670ac58b9 100644 --- a/apps/web/src/state/use-orchestration-command.test.ts +++ b/apps/web/src/state/use-orchestration-command.test.ts @@ -54,6 +54,19 @@ it("checks the command's environment instead of borrowing the primary grant", as expect(state.run).toHaveBeenCalledWith(target(first)); }); +it("checks a caller's permission instead of the live grant on every call", async () => { + const remembered = new Set([second]); + const mutate = useOrchestrationCommand(command, { reportFailure: false }, (environmentId) => + remembered.has(environmentId), + ); + expect(await mutate(target(second))).toMatchObject({ _tag: "Success", value: "receipt" }); + remembered.clear(); + const denied = await mutate(target(second)); + assert(denied._tag === "Failure"); + expect(Cause.squash(denied.cause)).toBeInstanceOf(EnvironmentAuthorizationError); + expect(state.run).toHaveBeenCalledOnce(); +}); + it("does not dispatch a later mutation after access changes while awaiting a receipt", async () => { state.allowed.add(second); let finish: (() => void) | undefined; diff --git a/apps/web/src/state/use-orchestration-command.ts b/apps/web/src/state/use-orchestration-command.ts index 1c801abc3c6b7..6ef149205011d 100644 --- a/apps/web/src/state/use-orchestration-command.ts +++ b/apps/web/src/state/use-orchestration-command.ts @@ -17,13 +17,19 @@ import { useAtomCommand } from "./use-atom-command"; export const ORCHESTRATION_OPERATE_DENIED_MESSAGE = "This connection cannot change threads or projects."; +/** + * `permitted` replaces the live operate check, so settle, snooze and their undo can pass the + * remembered offline grant. It runs on every call, like the default. + */ export function useOrchestrationCommand( command: AtomCommand, options?: string | AtomCommandOptions, + permitted: (environmentId: EnvironmentId) => boolean = (environmentId) => + readEnvironmentScope(environmentId, AuthOrchestrationOperateScope), ): (value: W) => Promise> { const run = useAtomCommand(command, options); return async (value: W): Promise> => { - if (!readEnvironmentScope(value.environmentId, AuthOrchestrationOperateScope)) { + if (!permitted(value.environmentId)) { return AsyncResult.failure( Cause.fail( new EnvironmentAuthorizationError({ diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 2ef056740f101..273dc9a2b6a5e 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -161,22 +161,13 @@ function makeEnvironmentSessionAtoms( ).pipe(Atom.withLabel(`environment-session-state-value:${environmentId}`)), ); - /** - * Null until a session loads, so offline callers can tell unknown from denied. Each loaded - * session gets its own object, so callers can tell a new session from a retained one. - */ - const operateGrants = new WeakMap(); + /** Null until a session loads, so offline callers can tell unknown from denied. */ const operateScopeAtom = Atom.family((environmentId: EnvironmentId) => - Atom.make((get): { readonly granted: boolean } | null => { + Atom.make((get): boolean | null => { const result = get(sessionStateAtom(environmentId)); - if (!AsyncResult.isSuccess(result)) return null; - const existing = operateGrants.get(result.value); - if (existing !== undefined) return existing; - const grant = { - granted: sessionGrantsScope(result.value, AuthOrchestrationOperateScope), - }; - operateGrants.set(result.value, grant); - return grant; + return AsyncResult.isSuccess(result) + ? sessionGrantsScope(result.value, AuthOrchestrationOperateScope) + : null; }).pipe(Atom.withLabel(`environment-session-operate-scope:${environmentId}`)), ); diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index e571b55191029..6b241bf53c151 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -108,6 +108,7 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio /** The guard capability in the cached config, which may differ from the live server's. */ readonly cachedVersionGuard?: boolean; readonly wrapStore?: (store: ThreadTriageOutboxStoreShape) => ThreadTriageOutboxStoreShape; + readonly registered?: boolean | null; }) { const requests = yield* Queue.unbounded<{ command: OrchestrationV2Command; @@ -179,9 +180,7 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio Atom.make(options?.connected === false ? "cached" : "live"), ); const operateScopeAtom = Atom.family((_environmentId: EnvironmentId) => - Atom.make<{ readonly granted: boolean } | null>( - options?.operateScope === null ? null : { granted: options?.operateScope ?? true }, - ), + Atom.make(options?.operateScope === undefined ? true : options.operateScope), ); const serverConfigAtom = Atom.family((_environmentId: EnvironmentId) => Atom.make({ @@ -190,10 +189,14 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio }, } as unknown as ServerConfig), ); + const registeredAtom = Atom.family((_environmentId: EnvironmentId) => + Atom.make(options?.registered === undefined ? true : options.registered), + ); const commands = createThreadEnvironmentAtoms(runtime, snapshotAtom, { shellStatusAtom: statusAtom, operateScopeAtom, serverConfigAtom, + registeredAtom, }); const registry = AtomRegistry.make(); yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); @@ -212,6 +215,7 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio snapshotAtom, statusAtom, operateScopeAtom, + registeredAtom, visibleAtom, requests, reconciled, @@ -854,7 +858,7 @@ describe("settle and snooze while the environment is offline", () => { yield* waitFor(coldOffline.registry, permitted, (value) => value); // A session that loads without the scope wins and is remembered for the next restart. - coldOffline.registry.set(coldOffline.operateScopeAtom(ENVIRONMENT_ID), { granted: false }); + coldOffline.registry.set(coldOffline.operateScopeAtom(ENVIRONMENT_ID), false); expect(coldOffline.registry.get(permitted)).toBe(false); yield* Queue.take(writes); const restarted = yield* makeHarness({ connected: false, storage, operateScope: null }); @@ -921,6 +925,7 @@ describe("settle and snooze while the environment is offline", () => { connected: false, storage, operateScope: true, + registered: false, wrapStore: (store) => ({ ...store, load: store.load.pipe(Effect.tap(() => store.clearEnvironment(ENVIRONMENT_ID))), @@ -954,6 +959,7 @@ describe("settle and snooze while the environment is offline", () => { const writes = yield* Queue.unbounded(); const storage = { value: null as string | null, writes }; let removeBeforeWrite = true; + let unregister = () => {}; const h = yield* makeHarness({ connected: false, storage, @@ -965,11 +971,13 @@ describe("settle and snooze while the environment is offline", () => { (removeBeforeWrite ? Effect.sync(() => { removeBeforeWrite = false; + unregister(); }).pipe(Effect.andThen(store.clearEnvironment(environmentId))) : Effect.void ).pipe(Effect.andThen(store.setOperable(environmentId, operable, stillCurrent))), }), }); + unregister = () => h.registry.set(h.registeredAtom(ENVIRONMENT_ID), false); const permitted = h.commands.offlineTriagePermittedAtom(ENVIRONMENT_ID); h.registry.mount(permitted); expect(JSON.parse(yield* Queue.take(writes)).operableEnvironmentIds).toEqual([]); @@ -981,8 +989,8 @@ describe("settle and snooze while the environment is offline", () => { operableEnvironmentIds: [], }); - // Adding the environment again brings a new session, which is remembered. - h.registry.set(h.operateScopeAtom(ENVIRONMENT_ID), { granted: true }); + // Adding the environment again lets its session be remembered. + h.registry.set(h.registeredAtom(ENVIRONMENT_ID), true); expect(h.registry.get(permitted)).toBe(true); expect(JSON.parse(yield* Queue.take(writes)).operableEnvironmentIds).toEqual([ ENVIRONMENT_ID, @@ -1015,4 +1023,53 @@ describe("settle and snooze while the environment is offline", () => { ); }), ); + + it.effect("queues the snapshot the action was taken against, not a later one", () => + Effect.gen(function* () { + const loaded = yield* Deferred.make(); + const h = yield* makeHarness({ + connected: false, + wrapStore: (store) => ({ + ...store, + load: Deferred.await(loaded).pipe(Effect.andThen(store.load)), + }), + }); + const queued = h.commands.settle.run(h.registry, settle); + // The shell moves on while the queue waits for storage. + h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), { + ...SNAPSHOT, + snapshotSequence: 5, + threads: [{ ...SNAPSHOT.threads[0]!, title: "Renamed on the Mac" }], + }); + yield* Deferred.succeed(loaded, undefined); + expect((yield* Effect.promise(() => queued))._tag).toBe("Success"); + expect(stored(h.storage)).toMatchObject([{ snapshotSequence: 1 }]); + }), + ); + + it.effect("does not remember a session that answers after its environment was removed", () => + Effect.gen(function* () { + const writes = yield* Queue.unbounded(); + const storage = { value: null as string | null, writes }; + const h = yield* makeHarness({ connected: false, storage, operateScope: null }); + const permitted = h.commands.offlineTriagePermittedAtom(ENVIRONMENT_ID); + h.registry.mount(permitted); + // Removed while its first session request is still waiting. + h.registry.set(h.registeredAtom(ENVIRONMENT_ID), false); + yield* h.store.clearEnvironment(ENVIRONMENT_ID); + yield* Queue.take(writes); + h.registry.set(h.operateScopeAtom(ENVIRONMENT_ID), true); + expect(h.registry.get(permitted)).toBe(false); + // The store runs writes in order, so this one follows any grant write. + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + expect(JSON.parse(yield* Queue.take(writes)).operableEnvironmentIds).toEqual([]); + + // Registering it again lets its session be remembered. + h.registry.set(h.registeredAtom(ENVIRONMENT_ID), true); + expect(h.registry.get(permitted)).toBe(true); + expect(JSON.parse(yield* Queue.take(writes)).operableEnvironmentIds).toEqual([ + ENVIRONMENT_ID, + ]); + }), + ); }); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 75e6c7bf6f8b2..c0dd12071264d 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -196,9 +196,9 @@ export function createThreadEnvironmentAtoms( /** Offline settle and snooze replay once this reports a synchronized shell. */ readonly shellStatusAtom?: (environmentId: EnvironmentId) => Atom.Atom; /** Whether the loaded session can change threads; null until it loads. */ - readonly operateScopeAtom?: ( - environmentId: EnvironmentId, - ) => Atom.Atom<{ readonly granted: boolean } | null>; + readonly operateScopeAtom?: (environmentId: EnvironmentId) => Atom.Atom; + /** Whether the environment is registered; null until the catalog loads. */ + readonly registeredAtom?: (environmentId: EnvironmentId) => Atom.Atom; /** Cached config; offline changes queue only for servers that can check them. */ readonly serverConfigAtom?: (environmentId: EnvironmentId) => Atom.Atom; } = {}, diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index a3da7734dc4ec..e295bb4facc43 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -92,13 +92,13 @@ export function createThreadTriageOutbox(input: { ) => Atom.Atom; /** Replay waits for a synchronized shell; without one, nothing replays. */ readonly shellStatusAtom?: (environmentId: EnvironmentId) => Atom.Atom; + /** Whether the loaded session can change threads; null until it loads. */ + readonly operateScopeAtom?: (environmentId: EnvironmentId) => Atom.Atom; /** - * Whether the loaded session can change threads; null until it loads. A new object means a - * newly loaded session, which is how a removed environment's old grant is told apart. + * Whether the environment is registered; null until the catalog loads. Only a registered + * environment's session may be remembered, so a removed one's late session grants nothing. */ - readonly operateScopeAtom?: ( - environmentId: EnvironmentId, - ) => Atom.Atom<{ readonly granted: boolean } | null>; + readonly registeredAtom?: (environmentId: EnvironmentId) => Atom.Atom; /** Cached config; only servers that check the snapshot sequence get queued changes. */ readonly serverConfigAtom?: (environmentId: EnvironmentId) => Atom.Atom; readonly applySettle: ( @@ -174,8 +174,6 @@ export function createThreadTriageOutbox(input: { /** Bumped when an environment is removed so in-flight work does not re-add its entries. */ const generations = new Map(); - /** The session grant current when each environment was removed. */ - const removedGrants = new Map(); const documentAtom = Atom.make(null).pipe(Atom.keepAlive); let loading: Promise | undefined; const ensureLoaded = (registry: AtomRegistry.AtomRegistry) => { @@ -212,10 +210,6 @@ export function createThreadTriageOutbox(input: { const claimed = new Set(); const clearLocal = (registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId) => { generations.set(environmentId, (generations.get(environmentId) ?? 0) + 1); - removedGrants.set( - environmentId, - input.operateScopeAtom ? registry.get(input.operateScopeAtom(environmentId)) : null, - ); const document = registry.get(documentAtom); if (document === null) return; registry.set(documentAtom, { @@ -346,6 +340,9 @@ export function createThreadTriageOutbox(input: { if (owns) claimed.add(claimKey); const generation = generations.get(environmentId) ?? 0; const current = () => (generations.get(environmentId) ?? 0) === generation; + // The server compares against what the user saw when acting, not what arrives later. + const snapshot = registry.get(input.sourceSnapshotAtom(environmentId)); + const thread = snapshot?.threads.find((candidate) => candidate.id === key.threadId); try { // Started before any await so the optimistic change shows in the same frame. const result = await command.run(registry, target); @@ -370,8 +367,6 @@ export function createThreadTriageOutbox(input: { ); } await ensureLoaded(registry); - const snapshot = registry.get(input.sourceSnapshotAtom(environmentId)); - const thread = snapshot?.threads.find((candidate) => candidate.id === key.threadId); if (snapshot == null || thread === undefined || !current()) return result; // The put replaces any earlier entry for the thread, so a failed save keeps it. const queued = await store.run(registry, { @@ -446,33 +441,34 @@ export function createThreadTriageOutbox(input: { */ const offlineTriagePermittedAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => { + const registeredAtom = input.registeredAtom?.(environmentId); + const registered = registeredAtom === undefined ? true : get(registeredAtom); + if (registered === false) return false; const known = input.operateScopeAtom ? get(input.operateScopeAtom(environmentId)) : null; const document = get(loadedDocumentAtom); const remembered = document?.operableEnvironmentIds.includes(environmentId) ?? false; if (known === null) return remembered; - // The session kept from before a removal must not restore its grant; a new one may. - if (removedGrants.has(environmentId)) { - if (removedGrants.get(environmentId) === known) return remembered; - removedGrants.delete(environmentId); - } const generation = generations.get(environmentId) ?? 0; - if (document !== null && known.granted !== remembered) { + if (registered === true && document !== null && known !== remembered) { const registry = get.registry; registry.set(documentAtom, { ...document, operableEnvironmentIds: [ ...document.operableEnvironmentIds.filter((id) => id !== environmentId), - ...(known.granted ? [environmentId] : []), + ...(known ? [environmentId] : []), ], }); void store.run(registry, { _tag: "setOperable", environmentId, - operable: known.granted, - stillCurrent: () => (generations.get(environmentId) ?? 0) === generation, + operable: known, + // Checked inside the store's lock, after any removal that started first. + stillCurrent: () => + (generations.get(environmentId) ?? 0) === generation && + (registeredAtom === undefined || registry.get(registeredAtom) === true), }); } - return known.granted; + return known; }), ); From e6c9ed5f17ca94492a61ca4fbff5aa32b80e88d2 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 16:11:29 +0000 Subject: [PATCH 07/44] fix(web): show Pending on a snoozed row waiting to sync The snoozed row's wake time took priority over the pending label, so an offline snooze showed no sign it had not synced. Pending now wins, as on mobile. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/sidebar/SidebarThreadRow.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 36bc06d325df2..de32668bc276c 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1167,7 +1167,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP !isWoke && "group-any-hover/sidebar-row:opacity-0", )} > - {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( + {!pendingSync && + variantAction === "unsnooze" && + props.snoozeWakeLabelText !== null ? ( {props.snoozeWakeLabelText} From e88b63b957baff41f34b8d505fdbb663d1169473 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 16:23:28 +0000 Subject: [PATCH 08/44] fix: drop the queued change a newer action replaced after its replay held the thread When a replay held a thread and failed transiently, a newer settle or snooze that waited behind it and then reached the server left the old queued change pending, so the row kept its stale overlay until the next replay was rejected. A successful action now removes the queued change that existed when it started, unless a replay holds it again. A change queued after the action started stays for the server to judge. Co-Authored-By: Claude Opus 5.5 --- .../src/state/threadCommands.test.ts | 61 +++++++++++++++++++ .../src/state/threadTriageOutbox.ts | 14 ++++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 6b241bf53c151..300f2509f998e 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -28,6 +28,7 @@ import { Atom, AtomRegistry } from "effect/reactivity"; import * as EnvironmentRegistry from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; import type { RpcSession } from "../rpc/session.ts"; import type { EnvironmentShellStatus } from "./shell.ts"; import { createThreadEnvironmentAtoms } from "./threadCommands.ts"; @@ -1072,4 +1073,64 @@ describe("settle and snooze while the environment is offline", () => { ]); }), ); + + it.effect("drops a queued change once a newer action reaches the server behind its replay", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false }); + const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); + h.registry.mount(pending); + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + yield* h.reconnect(); + const replay = yield* Queue.take(h.requests); + expect(replay.command.type).toBe("thread.settle"); + // Starts while the replay still holds the thread, so it waits behind it. + const newer = h.commands.snooze.run(h.registry, snooze); + yield* Deferred.fail( + replay.reply, + new EnvironmentRpcUnavailableError({ + environmentId: ENVIRONMENT_ID, + message: "Mac is not connected.", + }), + ); + const request = yield* Queue.take(h.requests); + expect(request.command.type).toBe("thread.snooze"); + yield* Deferred.succeed(request.reply, { sequence: 2 }); + expect((yield* Effect.promise(() => newer))._tag).toBe("Success"); + expect(h.registry.get(pending).size).toBe(0); + expect(stored(h.storage)).toEqual([]); + }), + ); + + it.effect("keeps a change queued while a newer action was reaching the server", () => + Effect.gen(function* () { + const putStarted = yield* Deferred.make(); + const putReleased = yield* Deferred.make(); + const h = yield* makeHarness({ + connected: false, + wrapStore: (store) => ({ + ...store, + put: (entry, stillCurrent) => + Deferred.succeed(putStarted, undefined).pipe( + Effect.andThen(Deferred.await(putReleased)), + Effect.andThen(store.put(entry, stillCurrent)), + ), + }), + }); + const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); + h.registry.mount(pending); + const queued = h.commands.settle.run(h.registry, settle); + yield* Deferred.await(putStarted); + yield* h.reconnect("synchronizing"); + const online = h.commands.snooze.run(h.registry, snooze); + const request = yield* Queue.take(h.requests); + // The queued change lands while the newer action is still on its way. + yield* Deferred.succeed(putReleased, undefined); + expect((yield* Effect.promise(() => queued))._tag).toBe("Success"); + yield* Deferred.succeed(request.reply, { sequence: 2 }); + expect((yield* Effect.promise(() => online))._tag).toBe("Success"); + // The server decides on replay; the newer action must not erase it locally. + expect(h.registry.get(pending).has(THREAD_ID)).toBe(true); + expect(stored(h.storage)).toMatchObject([{ action: { type: "settle" } }]); + }), + ); }); diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index e295bb4facc43..46a94a7a97fad 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -343,13 +343,21 @@ export function createThreadTriageOutbox(input: { // The server compares against what the user saw when acting, not what arrives later. const snapshot = registry.get(input.sourceSnapshotAtom(environmentId)); const thread = snapshot?.threads.find((candidate) => candidate.id === key.threadId); + const superseded = pendingFor(registry, key); try { // Started before any await so the optimistic change shows in the same frame. const result = await command.run(registry, target); if (AsyncResult.isSuccess(result)) { - // The server now has a newer action, which a queued one would only fight. - const previous = owns ? pendingFor(registry, key) : undefined; - if (previous !== undefined) await forget(registry, previous); + // The server now has a newer action, which the change it replaced would only fight. + // A change queued after this one started stays, and one a replay holds is left to it. + const previous = pendingFor(registry, key); + if ( + previous !== undefined && + previous === superseded && + (owns || !claimed.has(claimKey)) + ) { + await forget(registry, previous); + } return result; } const offline = offlineError(result.cause); From 8f4d8804a2da54d3c31c50076cc0966be91f08af Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 16:26:13 +0000 Subject: [PATCH 09/44] fix(web): keep the orchestration command hook compilable by React Compiler The inline arrow used as the permission parameter's default made React Compiler skip the hook, which the compiler coverage test rejects. The default check now lives at module scope and the parameter defaults to its name. The permission behavior is unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/state/use-orchestration-command.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/state/use-orchestration-command.ts b/apps/web/src/state/use-orchestration-command.ts index 6ef149205011d..ae3b39596294f 100644 --- a/apps/web/src/state/use-orchestration-command.ts +++ b/apps/web/src/state/use-orchestration-command.ts @@ -17,6 +17,11 @@ import { useAtomCommand } from "./use-atom-command"; export const ORCHESTRATION_OPERATE_DENIED_MESSAGE = "This connection cannot change threads or projects."; +// Module scope: React Compiler cannot reorder an inline arrow used as a parameter default. +function hasLiveOperateGrant(environmentId: EnvironmentId): boolean { + return readEnvironmentScope(environmentId, AuthOrchestrationOperateScope); +} + /** * `permitted` replaces the live operate check, so settle, snooze and their undo can pass the * remembered offline grant. It runs on every call, like the default. @@ -24,8 +29,7 @@ export const ORCHESTRATION_OPERATE_DENIED_MESSAGE = export function useOrchestrationCommand( command: AtomCommand, options?: string | AtomCommandOptions, - permitted: (environmentId: EnvironmentId) => boolean = (environmentId) => - readEnvironmentScope(environmentId, AuthOrchestrationOperateScope), + permitted: (environmentId: EnvironmentId) => boolean = hasLiveOperateGrant, ): (value: W) => Promise> { const run = useAtomCommand(command, options); return async (value: W): Promise> => { From 60daf540c44775fd28452713c8db9c16375b5944 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 16:32:05 +0000 Subject: [PATCH 10/44] fix: check shared storage before replaying a queued settle or snooze Browser tabs share the stored queue but each keeps its own copy in memory. A tab could replay a change another tab had already cancelled, and with no newer server event the server would accept it. Before sending, replay now rereads storage: a change that is gone is dropped, one replaced there is sent in its place, and nothing is sent if storage cannot be read. The tests give each queued change its own command id, as real ones have. Co-Authored-By: Claude Opus 5.5 --- .../src/state/threadCommands.test.ts | 89 ++++++++++++++++++- .../src/state/threadTriageOutbox.ts | 22 ++++- 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 300f2509f998e..c9ab248b96f09 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -96,13 +96,17 @@ const SNAPSHOT: OrchestrationV2ShellSnapshot = { ], }; +let randomCalls = 0; + const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (options?: { readonly reconcilePendingWork?: (threadId: ThreadId) => Effect.Effect; readonly connected?: boolean; readonly storage?: { value: string | null; failWrites?: boolean; + failReads?: boolean; writes?: Queue.Queue; + reads?: Queue.Queue; }; readonly versionGuard?: boolean; readonly operateScope?: boolean | null; @@ -141,7 +145,12 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]); const storage = options?.storage ?? { value: null }; const store = makeThreadTriageOutboxStore({ - read: Effect.sync(() => storage.value), + read: Effect.suspend(() => { + if (storage.reads) Queue.offerUnsafe(storage.reads, undefined); + return storage.failReads + ? Effect.fail(new ThreadTriageOutboxStorageError({ cause: "unreadable" })) + : Effect.succeed(storage.value); + }), write: (value) => storage.failWrites ? Effect.fail(new ThreadTriageOutboxStorageError({ cause: "disk full" })) @@ -159,7 +168,8 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio Layer.succeed( Crypto.Crypto, Crypto.make({ - randomBytes: (size) => new Uint8Array(size), + // Distinct bytes per call, so each queued change gets its own command id. + randomBytes: (size) => new Uint8Array(size).fill(++randomCalls % 256), digest: (_algorithm, data) => Effect.succeed(data), }), ), @@ -1133,4 +1143,79 @@ describe("settle and snooze while the environment is offline", () => { expect(stored(h.storage)).toMatchObject([{ action: { type: "settle" } }]); }), ); + + // Two browser tabs share one storage, but each keeps its own copy of the queue in memory. + const otherTab = (storage: { value: string | null }) => + Effect.gen(function* () { + const tab = yield* makeHarness({ connected: false, storage }); + const pending = tab.commands.pendingSyncAtom(ENVIRONMENT_ID); + tab.registry.mount(pending); + yield* waitFor(tab.registry, pending, (threads) => threads.has(THREAD_ID)); + return { tab, pending }; + }); + const sentOrDropped = ( + tab: Effect.Success>, + pending: Atom.Atom>, + ) => + Effect.raceFirst( + Queue.take(tab.requests).pipe(Effect.map((request) => request.command)), + waitFor(tab.registry, pending, (threads) => threads.size === 0).pipe( + Effect.as("dropped" as const), + ), + ); + + it.effect("does not replay a change another tab cancelled", () => + Effect.gen(function* () { + const storage = { value: null as string | null }; + const first = yield* makeHarness({ connected: false, storage }); + yield* Effect.promise(() => first.commands.settle.run(first.registry, settle)); + const { tab: second, pending } = yield* otherTab(storage); + const undo = yield* Effect.promise(() => + first.commands.unsettle.run(first.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, reason: "user" }, + }), + ); + expect(undo._tag).toBe("Success"); + yield* second.reconnect(); + expect(yield* sentOrDropped(second, pending)).toBe("dropped"); + expect(second.registry.get(second.visibleAtom)).toBe(SNAPSHOT); + }), + ); + + it.effect("replays the change another tab queued in place of an older one", () => + Effect.gen(function* () { + const storage = { value: null as string | null }; + const first = yield* makeHarness({ connected: false, storage }); + yield* Effect.promise(() => first.commands.settle.run(first.registry, settle)); + const { tab: second, pending } = yield* otherTab(storage); + yield* Effect.promise(() => first.commands.snooze.run(first.registry, snooze)); + const [replacement] = stored(storage); + yield* second.reconnect(); + expect(yield* sentOrDropped(second, pending)).toMatchObject({ + type: "thread.snooze", + commandId: replacement!.commandId, + }); + }), + ); + + it.effect("does not replay when it cannot read the stored queue", () => + Effect.gen(function* () { + const reads = yield* Queue.unbounded(); + const storage = { value: null as string | null, failReads: false, reads }; + const first = yield* makeHarness({ connected: false, storage }); + yield* Effect.promise(() => first.commands.settle.run(first.registry, settle)); + const { tab: second, pending } = yield* otherTab(storage); + yield* Queue.clear(reads); + storage.failReads = true; + yield* second.reconnect(); + yield* Queue.take(reads); + // A second reconnect only replays after the first attempt finished. + yield* second.disconnect; + yield* second.reconnect(); + yield* Queue.take(reads); + expect(yield* Queue.size(second.requests)).toBe(0); + expect(second.registry.get(pending).has(THREAD_ID)).toBe(true); + }), + ); }); diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index 46a94a7a97fad..d1ee9d21c173d 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -41,6 +41,7 @@ type Command = AtomCommand< type StoreOperation = | { readonly _tag: "load" } + | { readonly _tag: "read" } | { readonly _tag: "put"; readonly entry: Omit; @@ -133,6 +134,8 @@ export function createThreadTriageOutbox(input: { }).pipe(Effect.as(EMPTY_DOCUMENT)), ), ); + case "read": + return yield* outbox.load; case "put": { const crypto = yield* Crypto.Crypto; const entry = { @@ -275,9 +278,24 @@ export function createThreadTriageOutbox(input: { await forget(registry, entry); continue; } - const result = await input.replay(registry, entry); + // Another tab sharing the storage may have cancelled or replaced it. + const read = await store.run(registry, { _tag: "read" }); + if (!AsyncResult.isSuccess(read)) return; + if (pendingFor(registry, entry) !== entry) continue; + const stored = read.value.entries.find((candidate) => sameThreadTriage(candidate, entry)); + if (stored === undefined) { + setLocal(registry, (entries) => entries.filter((existing) => existing !== entry)); + continue; + } + const current = stored.commandId === entry.commandId ? entry : stored; + if (current !== entry) { + setLocal(registry, (entries) => + entries.map((existing) => (existing === entry ? current : existing)), + ); + } + const result = await input.replay(registry, current); if (AsyncResult.isSuccess(result) || !isTransient(result.cause)) { - await forget(registry, entry); + await forget(registry, current); } else { return; } From c24f97a7d8eec71fab7bd206b7506455f14ec067 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 16:38:12 +0000 Subject: [PATCH 11/44] fix: judge expiry on the replayed change and stop replay with its registry - Replay rereads storage before checking expiry, so an expired cached snooze that another tab replaced with a settle sends the settle, and an expired replacement is dropped without sending. - Replay keeps running after awaits, and could touch its registry after the registry was disposed, rejecting with "registry is disposed". A keep-alive atom now marks the registry's lifetime, and replay stops once it ends. Co-Authored-By: Claude Opus 5.5 --- .../src/state/threadCommands.test.ts | 35 +++++++++++++++++ .../src/state/threadTriageOutbox.ts | 39 ++++++++++++++----- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index c9ab248b96f09..848a026a28017 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -1218,4 +1218,39 @@ describe("settle and snooze while the environment is offline", () => { expect(second.registry.get(pending).has(THREAD_ID)).toBe(true); }), ); + + const expireStoredSnooze = (storage: { value: string | null }) => { + storage.value = storage.value!.replace("2099-01-01T00:00:00.000Z", "2000-01-01T00:00:00.000Z"); + }; + + it.effect("judges expiry by the replacement another tab stored, not the cached change", () => + Effect.gen(function* () { + const storage = { value: null as string | null }; + const first = yield* makeHarness({ connected: false, storage }); + yield* Effect.promise(() => first.commands.snooze.run(first.registry, snooze)); + expireStoredSnooze(storage); + const { tab: second, pending } = yield* otherTab(storage); + yield* Effect.promise(() => first.commands.settle.run(first.registry, settle)); + const [replacement] = stored(storage); + yield* second.reconnect(); + expect(yield* sentOrDropped(second, pending)).toMatchObject({ + type: "thread.settle", + commandId: replacement!.commandId, + }); + }), + ); + + it.effect("drops a replacement another tab stored once its snooze expired", () => + Effect.gen(function* () { + const storage = { value: null as string | null }; + const first = yield* makeHarness({ connected: false, storage }); + yield* Effect.promise(() => first.commands.settle.run(first.registry, settle)); + const { tab: second, pending } = yield* otherTab(storage); + yield* Effect.promise(() => first.commands.snooze.run(first.registry, snooze)); + expireStoredSnooze(storage); + yield* second.reconnect(); + expect(yield* sentOrDropped(second, pending)).toBe("dropped"); + expect(stored(storage)).toEqual([]); + }), + ); }); diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index d1ee9d21c173d..f16571554fcc5 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -230,13 +230,25 @@ export function createThreadTriageOutbox(input: { const document = registry.get(documentAtom) ?? EMPTY_DOCUMENT; registry.set(documentAtom, { ...document, entries: transform(document.entries) }); }; + /** Ends with the registry, so replay stops touching it once the app or a test tears it down. */ + const registryLifetimeAtom = Atom.make((get) => { + const lifetime = { ended: false }; + get.addFinalizer(() => { + lifetime.ended = true; + }); + return lifetime; + }).pipe(Atom.keepAlive); + /** * Hides the entry even if storage keeps it: a restart then replays it with the same command * id, which the server deduplicates or rejects. Cancelling must check the result instead. */ const forget = async (registry: AtomRegistry.AtomRegistry, entry: PendingThreadTriage) => { + const lifetime = registry.get(registryLifetimeAtom); const removed = await store.run(registry, { _tag: "remove", entry }); - setLocal(registry, (entries) => entries.filter((current) => current !== entry)); + if (!lifetime.ended) { + setLocal(registry, (entries) => entries.filter((current) => current !== entry)); + } return removed; }; @@ -247,12 +259,14 @@ export function createThreadTriageOutbox(input: { replayAgain.add(environmentId); return; } + const lifetime = registry.get(registryLifetimeAtom); replayingEnvironments.add(environmentId); try { - await replayEntries(registry, environmentId); + await replayEntries(registry, environmentId, lifetime); } finally { replayingEnvironments.delete(environmentId); } + if (lifetime.ended) return; if (replayAgain.delete(environmentId) && input.shellStatusAtom !== undefined) { if (registry.get(input.shellStatusAtom(environmentId)) === "live") { await replay(registry, environmentId); @@ -262,6 +276,7 @@ export function createThreadTriageOutbox(input: { const replayEntries = async ( registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId, + lifetime: { readonly ended: boolean }, ) => { for (const { threadId } of registry.get(environmentEntriesAtom(environmentId))) { // Earlier replies may have let Undo, a replacement, or a removal change this entry. @@ -271,16 +286,9 @@ export function createThreadTriageOutbox(input: { if (claimed.has(key)) continue; claimed.add(key); try { - if ( - entry.action.type === "snooze" && - !(Date.parse(entry.action.snoozedUntil) > DateTime.toEpochMillis(DateTime.nowUnsafe())) - ) { - await forget(registry, entry); - continue; - } // Another tab sharing the storage may have cancelled or replaced it. const read = await store.run(registry, { _tag: "read" }); - if (!AsyncResult.isSuccess(read)) return; + if (lifetime.ended || !AsyncResult.isSuccess(read)) return; if (pendingFor(registry, entry) !== entry) continue; const stored = read.value.entries.find((candidate) => sameThreadTriage(candidate, entry)); if (stored === undefined) { @@ -293,9 +301,20 @@ export function createThreadTriageOutbox(input: { entries.map((existing) => (existing === entry ? current : existing)), ); } + // Expiry belongs to the change actually sent, which may be another tab's replacement. + if ( + current.action.type === "snooze" && + !(Date.parse(current.action.snoozedUntil) > DateTime.toEpochMillis(DateTime.nowUnsafe())) + ) { + await forget(registry, current); + if (lifetime.ended) return; + continue; + } const result = await input.replay(registry, current); + if (lifetime.ended) return; if (AsyncResult.isSuccess(result) || !isTransient(result.cause)) { await forget(registry, current); + if (lifetime.ended) return; } else { return; } From d6f1350d7e7b34be6afcb2b49c1fd331c1acd4a8 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 17:01:44 +0000 Subject: [PATCH 12/44] fix(client-runtime): replay triage queued during reconnect --- .../src/state/threadCommands.test.ts | 32 +++++++++++++++++++ .../src/state/threadTriageOutbox.ts | 14 +++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 848a026a28017..69ca8a27b0b7f 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -618,6 +618,38 @@ describe("settle and snooze while the environment is offline", () => { }), ); + it.effect.each(["settle", "snooze"] as const)( + "replays a %s when reconnect finishes during the queue write", + (action) => + Effect.gen(function* () { + const putStarted = yield* Deferred.make(); + const putReleased = yield* Deferred.make(); + const h = yield* makeHarness({ + connected: false, + wrapStore: (store) => ({ + ...store, + put: (entry, stillCurrent) => + Deferred.succeed(putStarted, undefined).pipe( + Effect.andThen(Deferred.await(putReleased)), + Effect.andThen(store.put(entry, stillCurrent)), + ), + }), + }); + const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); + h.registry.mount(pending); + const queued = h.commands[action].run(h.registry, snooze); + yield* Deferred.await(putStarted); + yield* h.reconnect(); + yield* Deferred.succeed(putReleased, undefined); + expect((yield* Effect.promise(() => queued))._tag).toBe("Success"); + const request = yield* Queue.take(h.requests); + expect(request.command.type).toBe(`thread.${action}`); + expect(request.command.commandId).toBe(stored(h.storage)[0]!.commandId); + yield* Deferred.succeed(request.reply, { sequence: 2 }); + yield* waitFor(h.registry, pending, (threads) => threads.size === 0); + }), + ); + it.effect("restores a queued snooze after a restart and replays it once", () => Effect.gen(function* () { const storage = { value: null as string | null }; diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index f16571554fcc5..82850616cd3dc 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -372,6 +372,7 @@ export function createThreadTriageOutbox(input: { const { environmentId } = target; const key = { environmentId, threadId: target.input.threadId }; const claimKey = keyOf(key); + const lifetime = registry.get(registryLifetimeAtom); // A replay already holding the thread finishes first; the scheduler runs this after it. const owns = !claimed.has(claimKey); if (owns) claimed.add(claimKey); @@ -433,7 +434,18 @@ export function createThreadTriageOutbox(input: { ]); return AsyncResult.success({ sequence: snapshot.snapshotSequence, pendingSync: true }); } finally { - if (owns) claimed.delete(claimKey); + if (owns) { + claimed.delete(claimKey); + // Reconnect may have skipped this thread while its queue write held the claim. + if ( + !lifetime.ended && + input.shellStatusAtom !== undefined && + registry.get(input.shellStatusAtom(environmentId)) === "live" && + pendingFor(registry, key) !== undefined + ) { + void replay(registry, environmentId); + } + } } }, }; From 5c73439280804dfffcb4c34be9bec66ce0d809b5 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 17:19:11 +0000 Subject: [PATCH 13/44] fix: let the server undo a queued change that may already have landed A replay whose reply was lost may have reached the server, but Undo only dropped the local copy and reported success while the server kept the change. Before sending, replay now records the change as sent in storage, and sends nothing if it can't. Undo asks the store under its lock: only a change with the same command id that was never sent is cancelled locally. One that was sent, or that another tab already sent or cancelled, goes to the server. The record lives in storage, so it holds after a restart and across tabs. It is never cleared, because tabs replay independently and one tab's failed send says nothing about another's. Co-Authored-By: Claude Opus 5.5 --- .../src/platform/threadTriageOutboxStore.ts | 42 ++++++ .../src/state/threadCommands.test.ts | 133 ++++++++++++++++++ .../src/state/threadTriageOutbox.ts | 50 ++++++- 3 files changed, 220 insertions(+), 5 deletions(-) diff --git a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts index e9caa16349fab..7e3f3306c106c 100644 --- a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts +++ b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts @@ -15,6 +15,8 @@ export const PendingThreadTriage = Schema.Struct({ queuedAt: Schema.DateTimeUtcFromString, /** Shell sequence the action was taken against; the server rejects it after newer changes. */ snapshotSequence: NonNegativeInt, + /** Set before the first send and never cleared: the server may have it, so only it can undo. */ + attempted: Schema.optionalKey(Schema.Literal(true)), }); export type PendingThreadTriage = typeof PendingThreadTriage.Type; @@ -62,6 +64,17 @@ export interface ThreadTriageOutboxStoreShape { readonly remove: ( entry: ThreadTriageKey & Pick, ) => Effect.Effect; + /** Marks the entry as sent if it still has this command id; reports whether it did. */ + readonly markAttempted: ( + entry: ThreadTriageKey & Pick, + ) => Effect.Effect; + /** + * Removes the entry only if it still has this command id and was never sent. Otherwise it + * reports whether the entry was sent or is gone, and leaves storage as it is. + */ + readonly cancel: ( + entry: ThreadTriageKey & Pick, + ) => Effect.Effect<"cancelled" | "attempted" | "gone", ThreadTriageOutboxStorageError>; readonly setOperable: ( environmentId: EnvironmentId, operable: boolean, @@ -154,6 +167,35 @@ export function makeThreadTriageOutboxStore(storage: { (current) => !sameThreadTriage(current, entry) || current.commandId !== entry.commandId, ), ), + markAttempted: (entry) => + Effect.suspend(() => { + let found = false; + return update((document) => { + const stored = document.entries.find((current) => sameThreadTriage(current, entry)); + found = stored?.commandId === entry.commandId; + if (!found || stored?.attempted === true) return null; + return { + ...document, + entries: document.entries.map((current) => + current === stored ? { ...current, attempted: true as const } : current, + ), + }; + }).pipe(Effect.map(() => found)); + }), + cancel: (entry) => + Effect.suspend(() => { + let outcome: "cancelled" | "attempted" | "gone" = "gone"; + return update((document) => { + const stored = document.entries.find((current) => sameThreadTriage(current, entry)); + if (stored?.commandId !== entry.commandId) return null; + if (stored.attempted === true) { + outcome = "attempted"; + return null; + } + outcome = "cancelled"; + return { ...document, entries: document.entries.filter((current) => current !== stored) }; + }).pipe(Effect.map(() => outcome)); + }), setOperable: (environmentId, operable, stillCurrent) => update((document) => stillCurrent?.() === false diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 69ca8a27b0b7f..8752e39038422 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -25,6 +25,7 @@ import * as Queue from "effect/Queue"; import * as Scope from "effect/Scope"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom, AtomRegistry } from "effect/reactivity"; +import { RpcClientError } from "effect/rpc"; import * as EnvironmentRegistry from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; @@ -1067,6 +1068,54 @@ describe("settle and snooze while the environment is offline", () => { }), ); + /** The request may have reached the server before the socket closed. */ + const lostReply = () => + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "socket closed", + cause: new Error("socket closed"), + }), + }); + + it.effect("Undo after a replay lost its reply un-settles on the server", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false }); + const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); + h.registry.mount(pending); + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + yield* h.reconnect(); + const replay = yield* Queue.take(h.requests); + // Waits behind the replay, so its request shows the replay has let go of the thread. + const behind = h.commands.unsnooze.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, reason: "user" }, + }); + yield* Deferred.fail(replay.reply, lostReply()); + const behindRequest = yield* Queue.take(h.requests); + yield* Deferred.succeed(behindRequest.reply, { sequence: 2 }); + yield* Effect.promise(() => behind); + expect(h.registry.get(pending).has(THREAD_ID)).toBe(true); + + const undo = h.commands.unsettle.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, reason: "user" }, + }); + const sent = yield* Effect.raceFirst( + Queue.take(h.requests), + Effect.promise(() => undo).pipe(Effect.as("cancelled locally" as const)), + ); + expect(sent).toMatchObject({ command: { type: "thread.unsettle" } }); + if (sent === "cancelled locally") return; + yield* Deferred.succeed(sent.reply, { sequence: 3 }); + const result = yield* Effect.promise(() => undo); + expect(result._tag === "Success" && ThreadUndo.cancelledPendingSync(result.value)).toBe( + false, + ); + expect(h.registry.get(pending).size).toBe(0); + expect(stored(h.storage)).toEqual([]); + }), + ); + it.effect("queues the snapshot the action was taken against, not a later one", () => Effect.gen(function* () { const loaded = yield* Deferred.make(); @@ -1255,6 +1304,90 @@ describe("settle and snooze while the environment is offline", () => { storage.value = storage.value!.replace("2099-01-01T00:00:00.000Z", "2000-01-01T00:00:00.000Z"); }; + it.effect("Undo asks the server once another tab has sent the queued change", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false }); + h.registry.mount(h.commands.pendingSyncAtom(ENVIRONMENT_ID)); + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + const { tab, pending } = yield* otherTab(h.storage); + yield* tab.reconnect(); + const replay = yield* Queue.take(tab.requests); + yield* Deferred.succeed(replay.reply, { sequence: 2 }); + yield* waitFor(tab.registry, pending, (threads) => threads.size === 0); + + // This tab is still offline, so the server Undo it needs cannot reach the server. + const result = yield* Effect.promise(() => + h.commands.unsettle.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, reason: "user" }, + }), + ); + expect(result._tag).toBe("Failure"); + }), + ); + + it.effect.each([ + ["settle", "unsettle"], + ["snooze", "unsnooze"], + ] as const)( + "Undo after a restart or in another tab asks the server once a %s replay lost its reply", + ([action, undo]) => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false }); + h.registry.mount(h.commands.pendingSyncAtom(ENVIRONMENT_ID)); + yield* Effect.promise(() => h.commands[action].run(h.registry, snooze)); + yield* h.reconnect(); + const replay = yield* Queue.take(h.requests); + yield* Deferred.fail(replay.reply, lostReply()); + const { tab } = yield* otherTab(h.storage); + + // Still offline here, so the server Undo it needs cannot reach the server. + const result = yield* Effect.promise(() => + tab.commands[undo].run(tab.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, reason: "user" }, + }), + ); + expect(result._tag).toBe("Failure"); + expect(stored(h.storage)).toMatchObject([{ action: { type: action }, attempted: true }]); + }), + ); + + it.effect("does not send a queued change it cannot record as sent", () => + Effect.gen(function* () { + const markFailed = yield* Deferred.make(); + let failMark = true; + const h = yield* makeHarness({ + connected: false, + wrapStore: (store) => ({ + ...store, + markAttempted: (entry) => + failMark + ? Deferred.succeed(markFailed, undefined).pipe( + Effect.andThen( + Effect.fail(new ThreadTriageOutboxStorageError({ cause: "disk full" })), + ), + ) + : store.markAttempted(entry), + }), + }); + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + yield* h.reconnect(); + const first = yield* Effect.raceFirst( + Deferred.await(markFailed).pipe(Effect.as("not sent" as const)), + Queue.take(h.requests).pipe(Effect.as("sent" as const)), + ); + expect(first).toBe("not sent"); + failMark = false; + // A second reconnect only replays after the first attempt finished. + yield* h.disconnect; + yield* h.reconnect(); + const request = yield* Queue.take(h.requests); + expect(request.command.type).toBe("thread.settle"); + expect(yield* Queue.size(h.requests)).toBe(0); + }), + ); + it.effect("judges expiry by the replacement another tab stored, not the cached change", () => Effect.gen(function* () { const storage = { value: null as string | null }; diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index 82850616cd3dc..f3f56886ff040 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -48,6 +48,7 @@ type StoreOperation = readonly stillCurrent: () => boolean; } | { readonly _tag: "remove"; readonly entry: PendingThreadTriage } + | { readonly _tag: "markAttempted"; readonly entry: PendingThreadTriage } | { readonly _tag: "setOperable"; readonly environmentId: EnvironmentId; @@ -160,6 +161,16 @@ export function createThreadTriageOutbox(input: { ), ); return EMPTY_DOCUMENT; + case "markAttempted": { + const marked = yield* outbox.markAttempted(operation.entry).pipe( + Effect.tapError((error) => + Effect.logWarning("Could not record an offline thread change as sent.", { + cause: String(error.cause), + }), + ), + ); + return { ...EMPTY_DOCUMENT, entries: marked ? [operation.entry] : [] }; + } case "setOperable": yield* outbox .setOperable(operation.environmentId, operation.operable, operation.stillCurrent) @@ -175,6 +186,20 @@ export function createThreadTriageOutbox(input: { }), }); + const cancelStored = createRuntimeCommand(input.runtime, { + label: "environment-data:thread-triage-outbox-cancel", + execute: Effect.fn("ThreadTriageOutbox.cancel")(function* (entry: PendingThreadTriage) { + const outbox = yield* ThreadTriageOutboxStore; + return yield* outbox.cancel(entry).pipe( + Effect.tapError((error) => + Effect.logWarning("Could not cancel an offline thread change.", { + cause: String(error.cause), + }), + ), + ); + }), + }); + /** Bumped when an environment is removed so in-flight work does not re-add its entries. */ const generations = new Map(); const documentAtom = Atom.make(null).pipe(Atom.keepAlive); @@ -310,6 +335,13 @@ export function createThreadTriageOutbox(input: { if (lifetime.ended) return; continue; } + // Recorded before sending, so an Undo after a lost reply or a restart asks the server. + const marked = await store.run(registry, { _tag: "markAttempted", entry: current }); + if (lifetime.ended || !AsyncResult.isSuccess(marked)) return; + if (marked.value.entries.length === 0) { + setLocal(registry, (entries) => entries.filter((existing) => existing !== current)); + continue; + } const result = await input.replay(registry, current); if (lifetime.ended) return; if (AsyncResult.isSuccess(result) || !isTransient(result.cause)) { @@ -480,11 +512,19 @@ export function createThreadTriageOutbox(input: { } claimed.add(claimKey); try { - // A cancel that stays on disk would come back and replay after a restart. - const removed = await store.run(registry, { _tag: "remove", entry: pending }); - if (!AsyncResult.isSuccess(removed)) return AsyncResult.failure(removed.cause); - setLocal(registry, (entries) => entries.filter((current) => current !== pending)); - return AsyncResult.success(cancelledValue); + // Storage decides, because another tab or an earlier run may have sent it. + const outcome = await cancelStored.run(registry, pending); + if (!AsyncResult.isSuccess(outcome)) return AsyncResult.failure(outcome.cause); + if (outcome.value !== "attempted") { + setLocal(registry, (entries) => entries.filter((current) => current !== pending)); + } + if (outcome.value === "cancelled") return AsyncResult.success(cancelledValue); + const result = await command.run(registry, target); + // The undo is newer on the server, which then rejects the queued change anyway. + if (AsyncResult.isSuccess(result) && pendingFor(registry, pending) === pending) { + await forget(registry, pending); + } + return result; } finally { claimed.delete(claimKey); } From 2092c65818f26cf7ffd361f0c646c340debb763c Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 17:25:02 +0000 Subject: [PATCH 14/44] fix: keep the sent mark when a queued change is replaced Re-snoozing offline replaced a change that may already have reached the server with an unmarked one, so Undo cancelled it locally while the server kept the first snooze. The replacement now inherits the mark. Co-Authored-By: Claude Opus 5.5 --- .../src/platform/threadTriageOutboxStore.ts | 28 ++++++++++--------- .../src/state/threadCommands.test.ts | 28 +++++++++++++++++++ 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts index 7e3f3306c106c..d3600036f9ee1 100644 --- a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts +++ b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts @@ -53,8 +53,9 @@ export const sameThreadTriage = (a: ThreadTriageKey, b: ThreadTriageKey) => export interface ThreadTriageOutboxStoreShape { readonly load: Effect.Effect; /** - * Replaces any entry for the same thread. `stillCurrent` runs inside the store's lock, after - * any earlier removal, so a write for a removed environment is skipped. + * Replaces any entry for the same thread, keeping its `attempted` mark. `stillCurrent` runs + * inside the store's lock, after any earlier removal, so a write for a removed environment is + * skipped. */ readonly put: ( entry: PendingThreadTriage, @@ -150,17 +151,18 @@ export function makeThreadTriageOutboxStore(storage: { return { load: lock.withPermits(1)(load), put: (entry, stillCurrent) => - update((document) => - stillCurrent?.() === false - ? null - : { - ...document, - entries: [ - ...document.entries.filter((current) => !sameThreadTriage(current, entry)), - entry, - ], - }, - ), + update((document) => { + if (stillCurrent?.() === false) return null; + const replaced = document.entries.find((current) => sameThreadTriage(current, entry)); + return { + ...document, + entries: [ + ...document.entries.filter((current) => current !== replaced), + // The replaced change may have reached the server, so the server must undo this one. + replaced?.attempted === true ? { ...entry, attempted: true as const } : entry, + ], + }; + }), remove: (entry) => updateEntries((entries) => entries.filter( diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 8752e39038422..74cd5ac42aa06 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -1353,6 +1353,34 @@ describe("settle and snooze while the environment is offline", () => { }), ); + it.effect("Undo asks the server for a change that replaced one whose reply was lost", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false }); + h.registry.mount(h.commands.pendingSyncAtom(ENVIRONMENT_ID)); + yield* Effect.promise(() => h.commands.snooze.run(h.registry, snooze)); + yield* h.reconnect(); + const replay = yield* Queue.take(h.requests); + yield* h.disconnect; + yield* Deferred.fail(replay.reply, lostReply()); + const later = { + ...snooze, + input: { ...snooze.input, snoozedUntil: "2099-02-01T00:00:00.000Z" }, + }; + yield* Effect.promise(() => h.commands.snooze.run(h.registry, later)); + expect(stored(h.storage)).toMatchObject([ + { action: { type: "snooze", snoozedUntil: later.input.snoozedUntil }, attempted: true }, + ]); + + const result = yield* Effect.promise(() => + h.commands.unsnooze.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, reason: "user" }, + }), + ); + expect(result._tag).toBe("Failure"); + }), + ); + it.effect("does not send a queued change it cannot record as sent", () => Effect.gen(function* () { const markFailed = yield* Deferred.make(); From 1500acc9bc2aac7e48e380e1bce0ba8122758076 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 17:30:44 +0000 Subject: [PATCH 15/44] fix: read and mark a replayed change in one storage step Replay read the stored change, then marked it as sent in a second step. If another tab replaced the change between the two, the mark missed and this tab dropped the thread from memory, so it never replayed the replacement. Marking now returns whatever is stored for the thread, a replacement included, in the same locked step. Co-Authored-By: Claude Opus 5.5 --- .../src/platform/threadTriageOutboxStore.ts | 30 +++++++++++-------- .../src/state/threadCommands.test.ts | 4 +-- .../src/state/threadTriageOutbox.ts | 25 +++++----------- 3 files changed, 28 insertions(+), 31 deletions(-) diff --git a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts index d3600036f9ee1..ccbb835fd58b8 100644 --- a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts +++ b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts @@ -65,10 +65,13 @@ export interface ThreadTriageOutboxStoreShape { readonly remove: ( entry: ThreadTriageKey & Pick, ) => Effect.Effect; - /** Marks the entry as sent if it still has this command id; reports whether it did. */ + /** + * Marks whatever is stored for the thread as sent and returns it, which may be another tab's + * replacement. Returns nothing when the thread has no stored change. + */ readonly markAttempted: ( - entry: ThreadTriageKey & Pick, - ) => Effect.Effect; + key: ThreadTriageKey, + ) => Effect.Effect; /** * Removes the entry only if it still has this command id and was never sent. Otherwise it * reports whether the entry was sent or is gone, and leaves storage as it is. @@ -169,20 +172,23 @@ export function makeThreadTriageOutboxStore(storage: { (current) => !sameThreadTriage(current, entry) || current.commandId !== entry.commandId, ), ), - markAttempted: (entry) => + markAttempted: (key) => Effect.suspend(() => { - let found = false; + let marked: PendingThreadTriage | undefined; return update((document) => { - const stored = document.entries.find((current) => sameThreadTriage(current, entry)); - found = stored?.commandId === entry.commandId; - if (!found || stored?.attempted === true) return null; + const stored = document.entries.find((current) => sameThreadTriage(current, key)); + if (stored === undefined) return null; + if (stored.attempted === true) { + marked = stored; + return null; + } + const next = { ...stored, attempted: true as const }; + marked = next; return { ...document, - entries: document.entries.map((current) => - current === stored ? { ...current, attempted: true as const } : current, - ), + entries: document.entries.map((current) => (current === stored ? next : current)), }; - }).pipe(Effect.map(() => found)); + }).pipe(Effect.map(() => marked)); }), cancel: (entry) => Effect.suspend(() => { diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 74cd5ac42aa06..96daf5726ff5d 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -1389,14 +1389,14 @@ describe("settle and snooze while the environment is offline", () => { connected: false, wrapStore: (store) => ({ ...store, - markAttempted: (entry) => + markAttempted: (key) => failMark ? Deferred.succeed(markFailed, undefined).pipe( Effect.andThen( Effect.fail(new ThreadTriageOutboxStorageError({ cause: "disk full" })), ), ) - : store.markAttempted(entry), + : store.markAttempted(key), }), }); yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index f3f56886ff040..9e3ed90effa56 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -41,14 +41,13 @@ type Command = AtomCommand< type StoreOperation = | { readonly _tag: "load" } - | { readonly _tag: "read" } | { readonly _tag: "put"; readonly entry: Omit; readonly stillCurrent: () => boolean; } | { readonly _tag: "remove"; readonly entry: PendingThreadTriage } - | { readonly _tag: "markAttempted"; readonly entry: PendingThreadTriage } + | { readonly _tag: "markAttempted"; readonly key: ThreadTriageKey } | { readonly _tag: "setOperable"; readonly environmentId: EnvironmentId; @@ -135,8 +134,6 @@ export function createThreadTriageOutbox(input: { }).pipe(Effect.as(EMPTY_DOCUMENT)), ), ); - case "read": - return yield* outbox.load; case "put": { const crypto = yield* Crypto.Crypto; const entry = { @@ -162,14 +159,14 @@ export function createThreadTriageOutbox(input: { ); return EMPTY_DOCUMENT; case "markAttempted": { - const marked = yield* outbox.markAttempted(operation.entry).pipe( + const marked = yield* outbox.markAttempted(operation.key).pipe( Effect.tapError((error) => Effect.logWarning("Could not record an offline thread change as sent.", { cause: String(error.cause), }), ), ); - return { ...EMPTY_DOCUMENT, entries: marked ? [operation.entry] : [] }; + return { ...EMPTY_DOCUMENT, entries: marked === undefined ? [] : [marked] }; } case "setOperable": yield* outbox @@ -311,11 +308,12 @@ export function createThreadTriageOutbox(input: { if (claimed.has(key)) continue; claimed.add(key); try { - // Another tab sharing the storage may have cancelled or replaced it. - const read = await store.run(registry, { _tag: "read" }); - if (lifetime.ended || !AsyncResult.isSuccess(read)) return; + // Marked before sending, so an Undo after a lost reply, a restart, or in another tab asks + // the server. Storage also shows whether another tab cancelled or replaced the change. + const marked = await store.run(registry, { _tag: "markAttempted", key: entry }); + if (lifetime.ended || !AsyncResult.isSuccess(marked)) return; if (pendingFor(registry, entry) !== entry) continue; - const stored = read.value.entries.find((candidate) => sameThreadTriage(candidate, entry)); + const stored = marked.value.entries[0]; if (stored === undefined) { setLocal(registry, (entries) => entries.filter((existing) => existing !== entry)); continue; @@ -335,13 +333,6 @@ export function createThreadTriageOutbox(input: { if (lifetime.ended) return; continue; } - // Recorded before sending, so an Undo after a lost reply or a restart asks the server. - const marked = await store.run(registry, { _tag: "markAttempted", entry: current }); - if (lifetime.ended || !AsyncResult.isSuccess(marked)) return; - if (marked.value.entries.length === 0) { - setLocal(registry, (entries) => entries.filter((existing) => existing !== current)); - continue; - } const result = await input.replay(registry, current); if (lifetime.ended) return; if (AsyncResult.isSuccess(result) || !isTransient(result.cause)) { From cc4739852bbf17381d72b5a7c0d388f88bbe0e33 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 17:32:06 +0000 Subject: [PATCH 16/44] fix(mobile): restore snooze during offline settle undo --- .../home/useThreadListActions.test.ts | 24 ++++++++++++++++++- .../src/features/home/useThreadListActions.ts | 2 +- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 8f797881bd45d..61b1afcd524e9 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -144,7 +144,11 @@ vi.mock("../../state/threads", () => ({ input: { threadId: string; orderKey?: string }; }) => { state.requests.push({ action, ...request }); - if (!state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope)) { + const triage = ["settle", "unsettle", "snooze", "unsnooze"].includes(action); + if ( + !state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope) && + !(triage && state.offlineTriage.has(request.environmentId)) + ) { return AsyncResult.failure(Cause.fail(new Error("Thread operation denied"))); } if (action === state.failedAction) @@ -376,6 +380,24 @@ describe("thread action undo", () => { expect(readThreadUndoNotice()).toBeNull(); }); + it("restores snooze with the remembered grant if the session disappears during Undo", async () => { + const thread = makeThread({ snoozedUntil: "2099-01-01T09:00:00.000Z" }); + expect(await useThreadListActions().settleThread(thread)).toBe(true); + state.afterRequest = () => { + state.scopes.delete(primaryEnvironmentId); + state.offlineTriage.add(primaryEnvironmentId); + }; + + await readThreadUndoNotice()!.undo(); + + expect(state.requests.map(({ action }) => action)).toEqual(["settle", "unsettle", "snooze"]); + expect(state.requests.at(-1)?.input).toEqual({ + threadId: thread.id, + snoozedUntil: thread.snoozedUntil, + }); + expect(state.alerts).toEqual([]); + }); + // Whether the queued settle reached the server is known only when Undo runs. it.each([ ["cancels it before it syncs", { sequence: 0, cancelledPendingSync: true }, []], diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index a923dadc48ea8..91274d358cce7 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -301,7 +301,7 @@ export function useThreadListActions(): { case "snooze": { const failureTitle = action === "Unpinned" ? "Could not undo unpin" : "Could not undo settle"; - if (!checkThreadOperationPermission(thread, failureTitle)) return false; + if (!checkThreadOperationPermission(thread, failureTitle, true)) return false; const result = await snoozeMutation({ environmentId: thread.environmentId, input: { threadId: thread.id, snoozedUntil: step.snoozedUntil }, From 7f8fc8238ab93053eab0d8601295f7ef4a95092d Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 17:40:24 +0000 Subject: [PATCH 17/44] fix: serialize the offline queue across tabs and replay after a failed Undo - Each read-modify-write of the stored queue now runs under a Web Lock on web, so tabs in different processes can't overwrite each other's changes. - An Undo holding a thread while the shell reconnected left its queued change unsent when the Undo failed. Releasing a claim now replays what is still queued, for Undo as it already did for queuing. Co-Authored-By: Claude Opus 5.5 --- .../src/connection/threadTriageOutboxStore.ts | 7 +++ .../src/platform/threadTriageOutboxStore.ts | 27 ++++---- .../src/state/threadCommands.test.ts | 62 +++++++++++++++++++ .../src/state/threadTriageOutbox.ts | 33 ++++++---- 4 files changed, 105 insertions(+), 24 deletions(-) diff --git a/apps/web/src/connection/threadTriageOutboxStore.ts b/apps/web/src/connection/threadTriageOutboxStore.ts index c645477bc8c5c..e360fbf24cf9d 100644 --- a/apps/web/src/connection/threadTriageOutboxStore.ts +++ b/apps/web/src/connection/threadTriageOutboxStore.ts @@ -17,4 +17,11 @@ export const browserThreadTriageOutboxStore = makeThreadTriageOutboxStore({ try: () => window.localStorage.setItem(STORAGE_KEY, value), catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), }), + // Tabs in other processes share localStorage, and only a Web Lock serializes them. + exclusive: (effect) => + typeof navigator === "undefined" || navigator.locks === undefined + ? effect + : Effect.promise(() => + navigator.locks.request(STORAGE_KEY, () => Effect.runPromiseExit(effect)), + ).pipe(Effect.flatten), }); diff --git a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts index ccbb835fd58b8..1fea169149a49 100644 --- a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts +++ b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts @@ -113,7 +113,10 @@ export class ThreadTriageOutboxStore extends Context.Reference; readonly write: (value: string) => Effect.Effect; + /** Serializes each read-modify-write with other processes sharing the storage, such as tabs. */ + readonly exclusive?: (effect: Effect.Effect) => Effect.Effect; }): ThreadTriageOutboxStoreShape { + const exclusive = storage.exclusive ?? ((effect) => effect); const lock = Semaphore.makeUnsafe(1); const clearedListeners = new Set<(environmentId: EnvironmentId) => void>(); const load = storage.read.pipe( @@ -135,17 +138,19 @@ export function makeThreadTriageOutboxStore(storage: { after: Effect.Effect = Effect.void, ) => lock.withPermits(1)( - load.pipe( - Effect.flatMap((document) => { - const next = transform(document); - return next === null - ? Effect.void - : encodeStored(next).pipe( - Effect.mapError((cause) => new ThreadTriageOutboxStorageError({ cause })), - Effect.flatMap(storage.write), - ); - }), - Effect.ensuring(after), + exclusive( + load.pipe( + Effect.flatMap((document) => { + const next = transform(document); + return next === null + ? Effect.void + : encodeStored(next).pipe( + Effect.mapError((cause) => new ThreadTriageOutboxStorageError({ cause })), + Effect.flatMap(storage.write), + ); + }), + Effect.ensuring(after), + ), ), ); const updateEntries = ( diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 96daf5726ff5d..306ad9cec48d6 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -23,6 +23,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom, AtomRegistry } from "effect/reactivity"; import { RpcClientError } from "effect/rpc"; @@ -651,6 +652,67 @@ describe("settle and snooze while the environment is offline", () => { }), ); + it.effect("replays a queued change when an Undo that failed lets go after reconnect", () => + Effect.gen(function* () { + const cancelStarted = yield* Deferred.make(); + const cancelReleased = yield* Deferred.make(); + const h = yield* makeHarness({ + connected: false, + wrapStore: (store) => ({ + ...store, + cancel: () => + Deferred.succeed(cancelStarted, undefined).pipe( + Effect.andThen(Deferred.await(cancelReleased)), + Effect.andThen( + Effect.fail(new ThreadTriageOutboxStorageError({ cause: "disk full" })), + ), + ), + }), + }); + h.registry.mount(h.commands.pendingSyncAtom(ENVIRONMENT_ID)); + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + const undo = h.commands.unsettle.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, reason: "user" }, + }); + yield* Deferred.await(cancelStarted); + yield* h.reconnect(); + yield* Deferred.succeed(cancelReleased, undefined); + expect((yield* Effect.promise(() => undo))._tag).toBe("Failure"); + const request = yield* Queue.take(h.requests); + expect(request.command.type).toBe("thread.settle"); + }), + ); + + it.effect("keeps every tab's change when a shared lock serializes their writes", () => + Effect.gen(function* () { + const shared = { value: null as string | null }; + const crossTab = Semaphore.makeUnsafe(1); + // Yields after reading, as another process's write can land before this one writes. + const makeTab = () => + makeThreadTriageOutboxStore({ + read: Effect.sync(() => shared.value).pipe(Effect.tap(() => Effect.yieldNow)), + write: (value) => + Effect.sync(() => { + shared.value = value; + }), + exclusive: (effect) => crossTab.withPermits(1)(effect), + }); + const change = (threadId: string) => ({ + environmentId: ENVIRONMENT_ID, + threadId: ThreadId.make(threadId), + commandId: CommandId.make(`command-${threadId}`), + action: { type: "settle" as const }, + queuedAt: NOW, + snapshotSequence: 1, + }); + yield* Effect.all([makeTab().put(change("first")), makeTab().put(change("second"))], { + concurrency: "unbounded", + }); + expect(stored(shared).map((entry) => entry.threadId)).toEqual(["first", "second"]); + }), + ); + it.effect("restores a queued snooze after a restart and replays it once", () => Effect.gen(function* () { const storage = { value: null as string | null }; diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index 9e3ed90effa56..40beadf057e34 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -274,6 +274,23 @@ export function createThreadTriageOutbox(input: { return removed; }; + /** Reconnect skips a claimed thread, so whoever releases one replays what is still queued. */ + const release = ( + registry: AtomRegistry.AtomRegistry, + key: ThreadTriageKey, + lifetime: { readonly ended: boolean }, + ) => { + claimed.delete(keyOf(key)); + if ( + !lifetime.ended && + input.shellStatusAtom !== undefined && + registry.get(input.shellStatusAtom(key.environmentId)) === "live" && + pendingFor(registry, key) !== undefined + ) { + void replay(registry, key.environmentId); + } + }; + const replayingEnvironments = new Set(); const replayAgain = new Set(); const replay = async (registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId) => { @@ -457,18 +474,7 @@ export function createThreadTriageOutbox(input: { ]); return AsyncResult.success({ sequence: snapshot.snapshotSequence, pendingSync: true }); } finally { - if (owns) { - claimed.delete(claimKey); - // Reconnect may have skipped this thread while its queue write held the claim. - if ( - !lifetime.ended && - input.shellStatusAtom !== undefined && - registry.get(input.shellStatusAtom(environmentId)) === "live" && - pendingFor(registry, key) !== undefined - ) { - void replay(registry, environmentId); - } - } + if (owns) release(registry, key, lifetime); } }, }; @@ -501,6 +507,7 @@ export function createThreadTriageOutbox(input: { // A claimed entry may already be on its way; the server undoes it after it lands. return command.run(registry, target); } + const lifetime = registry.get(registryLifetimeAtom); claimed.add(claimKey); try { // Storage decides, because another tab or an earlier run may have sent it. @@ -517,7 +524,7 @@ export function createThreadTriageOutbox(input: { } return result; } finally { - claimed.delete(claimKey); + release(registry, pending, lifetime); } }, }; From c18be0f921ef93184877da850f9d624d73eb41e8 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 17:51:25 +0000 Subject: [PATCH 18/44] fix(web): reject offline queue writes without a shared lock --- .../threadTriageOutboxStore.test.ts | 97 +++++++++++++++++++ .../src/connection/threadTriageOutboxStore.ts | 15 ++- docs/user/thread-sidebar.md | 2 + .../src/platform/threadTriageOutboxStore.ts | 7 +- 4 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/connection/threadTriageOutboxStore.test.ts diff --git a/apps/web/src/connection/threadTriageOutboxStore.test.ts b/apps/web/src/connection/threadTriageOutboxStore.test.ts new file mode 100644 index 0000000000000..620f4de49a460 --- /dev/null +++ b/apps/web/src/connection/threadTriageOutboxStore.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "@effect/vitest"; +import { CommandId, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { ThreadTriageOutboxStorageError } from "@t3tools/client-runtime/platform"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import { afterEach, vi } from "vite-plus/test"; + +import { browserThreadTriageOutboxStore } from "./threadTriageOutboxStore"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("browser thread triage storage", () => { + it.effect.each(["unavailable", "rejected"] as const)( + "refuses to save queued changes when the shared lock is %s", + (lock) => + Effect.gen(function* () { + const setItem = vi.fn(); + vi.stubGlobal("window", { localStorage: { getItem: () => null, setItem } }); + vi.stubGlobal( + "navigator", + lock === "unavailable" + ? {} + : { locks: { request: () => Promise.reject(new Error("Lock request denied")) } }, + ); + + const result = yield* Effect.exit( + browserThreadTriageOutboxStore.put({ + environmentId: EnvironmentId.make("remote"), + threadId: ThreadId.make("thread"), + commandId: CommandId.make("queued-settle"), + action: { type: "settle" }, + queuedAt: DateTime.makeUnsafe("2026-09-12T10:00:00.000Z"), + snapshotSequence: 1, + }), + ); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.squash(result.cause)).toBeInstanceOf(ThreadTriageOutboxStorageError); + } + expect(setItem).not.toHaveBeenCalled(); + }), + ); + + it.effect("persists a queued change when the shared lock is available", () => + Effect.gen(function* () { + let raw: string | null = null; + vi.stubGlobal("window", { + localStorage: { + getItem: () => raw, + setItem: (_key: string, value: string) => { + raw = value; + }, + }, + }); + vi.stubGlobal("navigator", { + locks: { request: (_name: string, callback: () => Promise) => callback() }, + }); + const entry = { + environmentId: EnvironmentId.make("remote"), + threadId: ThreadId.make("thread"), + commandId: CommandId.make("queued-settle"), + action: { type: "settle" as const }, + queuedAt: DateTime.makeUnsafe("2026-09-12T10:00:00.000Z"), + snapshotSequence: 1, + }; + + yield* browserThreadTriageOutboxStore.put(entry); + + expect((yield* browserThreadTriageOutboxStore.load).entries).toEqual([entry]); + }), + ); + + it.effect("clears retained environment state even when its storage lock is unavailable", () => + Effect.gen(function* () { + vi.stubGlobal("navigator", {}); + const environmentId = EnvironmentId.make("removed"); + const retained = new Set([environmentId]); + yield* Effect.acquireRelease( + Effect.sync(() => + browserThreadTriageOutboxStore.subscribeCleared((removed) => retained.delete(removed)), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ); + + const result = yield* Effect.exit( + browserThreadTriageOutboxStore.clearEnvironment(environmentId), + ); + + expect(result._tag).toBe("Failure"); + expect(retained.size).toBe(0); + }), + ); +}); diff --git a/apps/web/src/connection/threadTriageOutboxStore.ts b/apps/web/src/connection/threadTriageOutboxStore.ts index e360fbf24cf9d..cdd9a1c4255e9 100644 --- a/apps/web/src/connection/threadTriageOutboxStore.ts +++ b/apps/web/src/connection/threadTriageOutboxStore.ts @@ -17,11 +17,16 @@ export const browserThreadTriageOutboxStore = makeThreadTriageOutboxStore({ try: () => window.localStorage.setItem(STORAGE_KEY, value), catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), }), - // Tabs in other processes share localStorage, and only a Web Lock serializes them. + // Reject writes without cross-tab exclusion so shared localStorage changes cannot be lost. exclusive: (effect) => typeof navigator === "undefined" || navigator.locks === undefined - ? effect - : Effect.promise(() => - navigator.locks.request(STORAGE_KEY, () => Effect.runPromiseExit(effect)), - ).pipe(Effect.flatten), + ? Effect.fail( + new ThreadTriageOutboxStorageError({ + cause: new Error("Offline thread changes require Web Locks. Use HTTPS or localhost."), + }), + ) + : Effect.tryPromise({ + try: () => navigator.locks.request(STORAGE_KEY, () => Effect.runPromiseExit(effect)), + catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), + }).pipe(Effect.flatten), }); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 4e2d10786a3a6..34514f8d686b1 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -144,6 +144,8 @@ You can settle or snooze a thread while its environment is offline. The thread shows **Waiting to sync** on mobile or **Pending** on web and desktop, and the change is sent when the environment reconnects. If the thread changed there in the meantime, that environment's state wins and your change is dropped. +On the web, offline changes require a browser with Web Locks support and an HTTPS +or localhost connection. To reclaim disk space from settled work, turn on **Run in the thread's worktree when the thread settles** for one of the project's actions, or set `"runOnSettle": true` on a diff --git a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts index 1fea169149a49..e10393b56b85c 100644 --- a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts +++ b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts @@ -114,7 +114,9 @@ export function makeThreadTriageOutboxStore(storage: { readonly read: Effect.Effect; readonly write: (value: string) => Effect.Effect; /** Serializes each read-modify-write with other processes sharing the storage, such as tabs. */ - readonly exclusive?: (effect: Effect.Effect) => Effect.Effect; + readonly exclusive?: ( + effect: Effect.Effect, + ) => Effect.Effect; }): ThreadTriageOutboxStoreShape { const exclusive = storage.exclusive ?? ((effect) => effect); const lock = Semaphore.makeUnsafe(1); @@ -149,9 +151,8 @@ export function makeThreadTriageOutboxStore(storage: { Effect.flatMap(storage.write), ); }), - Effect.ensuring(after), ), - ), + ).pipe(Effect.ensuring(after)), ); const updateEntries = ( transform: (entries: ReadonlyArray) => ReadonlyArray, From c107bb61bde95cf72238a9e1d12fc2d8c840d159 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 18:10:51 +0000 Subject: [PATCH 19/44] refactor(web): hold the offline queue's Web Lock on the caller's fiber The lock callback ran the locked work with Effect.runPromiseExit, which started a separate root fiber and dropped the caller's context. The lock is now acquired and released around the work, which runs on the caller's fiber. Co-Authored-By: Claude Opus 5.5 --- .../src/connection/threadTriageOutboxStore.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/web/src/connection/threadTriageOutboxStore.ts b/apps/web/src/connection/threadTriageOutboxStore.ts index cdd9a1c4255e9..77e894d931e82 100644 --- a/apps/web/src/connection/threadTriageOutboxStore.ts +++ b/apps/web/src/connection/threadTriageOutboxStore.ts @@ -6,6 +6,18 @@ import * as Effect from "effect/Effect"; const STORAGE_KEY = "t3code:thread-triage-outbox"; +/** Resolves with the release once the lock is granted, so the locked work runs on the caller's fiber. */ +const acquireLock = (locks: LockManager) => + Effect.tryPromise({ + try: () => + new Promise<() => void>((granted, rejected) => { + locks + .request(STORAGE_KEY, () => new Promise((release) => granted(() => release()))) + .catch(rejected); + }), + catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), + }); + /** Kept out of the environment cache so clearing cached data keeps unsynced settles and snoozes. */ export const browserThreadTriageOutboxStore = makeThreadTriageOutboxStore({ read: Effect.try({ @@ -25,8 +37,9 @@ export const browserThreadTriageOutboxStore = makeThreadTriageOutboxStore({ cause: new Error("Offline thread changes require Web Locks. Use HTTPS or localhost."), }), ) - : Effect.tryPromise({ - try: () => navigator.locks.request(STORAGE_KEY, () => Effect.runPromiseExit(effect)), - catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), - }).pipe(Effect.flatten), + : Effect.acquireUseRelease( + acquireLock(navigator.locks), + () => effect, + (release) => Effect.sync(release), + ), }); From dfdc648c463a4b22659ef172e4d638b22132335d Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 18:18:00 +0000 Subject: [PATCH 20/44] fix(web): cancel pending offline queue lock requests --- .../threadTriageOutboxStore.test.ts | 155 ++++++++++++++---- .../src/connection/threadTriageOutboxStore.ts | 43 +++-- 2 files changed, 147 insertions(+), 51 deletions(-) diff --git a/apps/web/src/connection/threadTriageOutboxStore.test.ts b/apps/web/src/connection/threadTriageOutboxStore.test.ts index 620f4de49a460..6de54fe871bbc 100644 --- a/apps/web/src/connection/threadTriageOutboxStore.test.ts +++ b/apps/web/src/connection/threadTriageOutboxStore.test.ts @@ -4,10 +4,22 @@ import { ThreadTriageOutboxStorageError } from "@t3tools/client-runtime/platform import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Logger from "effect/Logger"; +import * as Queue from "effect/Queue"; import { afterEach, vi } from "vite-plus/test"; import { browserThreadTriageOutboxStore } from "./threadTriageOutboxStore"; +const queuedSettle = { + environmentId: EnvironmentId.make("remote"), + threadId: ThreadId.make("thread"), + commandId: CommandId.make("queued-settle"), + action: { type: "settle" as const }, + queuedAt: DateTime.makeUnsafe("2026-09-12T10:00:00.000Z"), + snapshotSequence: 1, +}; + afterEach(() => { vi.unstubAllGlobals(); }); @@ -26,16 +38,7 @@ describe("browser thread triage storage", () => { : { locks: { request: () => Promise.reject(new Error("Lock request denied")) } }, ); - const result = yield* Effect.exit( - browserThreadTriageOutboxStore.put({ - environmentId: EnvironmentId.make("remote"), - threadId: ThreadId.make("thread"), - commandId: CommandId.make("queued-settle"), - action: { type: "settle" }, - queuedAt: DateTime.makeUnsafe("2026-09-12T10:00:00.000Z"), - snapshotSequence: 1, - }), - ); + const result = yield* Effect.exit(browserThreadTriageOutboxStore.put(queuedSettle)); expect(result._tag).toBe("Failure"); if (result._tag === "Failure") { @@ -45,33 +48,44 @@ describe("browser thread triage storage", () => { }), ); - it.effect("persists a queued change when the shared lock is available", () => - Effect.gen(function* () { - let raw: string | null = null; - vi.stubGlobal("window", { - localStorage: { - getItem: () => raw, - setItem: (_key: string, value: string) => { - raw = value; + it.effect.each(["success", "failure"] as const)( + "releases the acquired lock after storage %s", + (outcome) => + Effect.gen(function* () { + const released = yield* Queue.unbounded(); + let raw: string | null = null; + vi.stubGlobal("window", { + localStorage: { + getItem: () => raw, + setItem: (_key: string, value: string) => { + if (outcome === "failure") throw new Error("Storage write denied"); + raw = value; + }, }, - }, - }); - vi.stubGlobal("navigator", { - locks: { request: (_name: string, callback: () => Promise) => callback() }, - }); - const entry = { - environmentId: EnvironmentId.make("remote"), - threadId: ThreadId.make("thread"), - commandId: CommandId.make("queued-settle"), - action: { type: "settle" as const }, - queuedAt: DateTime.makeUnsafe("2026-09-12T10:00:00.000Z"), - snapshotSequence: 1, - }; - - yield* browserThreadTriageOutboxStore.put(entry); - - expect((yield* browserThreadTriageOutboxStore.load).entries).toEqual([entry]); - }), + }); + vi.stubGlobal("navigator", { + locks: { + request: async ( + _name: string, + _options: LockOptions, + callback: () => Promise, + ) => { + try { + return await callback(); + } finally { + Queue.offerUnsafe(released, undefined); + } + }, + }, + }); + const result = yield* Effect.exit(browserThreadTriageOutboxStore.put(queuedSettle)); + yield* Queue.take(released); + + expect(result._tag).toBe(outcome === "success" ? "Success" : "Failure"); + expect((yield* browserThreadTriageOutboxStore.load).entries).toEqual( + outcome === "success" ? [queuedSettle] : [], + ); + }), ); it.effect("clears retained environment state even when its storage lock is unavailable", () => @@ -94,4 +108,73 @@ describe("browser thread triage storage", () => { expect(retained.size).toBe(0); }), ); + + it.effect("cancels a pending lock request before it can write", () => + Effect.gen(function* () { + const requested = yield* Queue.unbounded<{ + signal: AbortSignal | undefined; + grant: () => Promise; + }>(); + const setItem = vi.fn(); + vi.stubGlobal("window", { localStorage: { getItem: () => null, setItem } }); + vi.stubGlobal("navigator", { + locks: { + request: ( + _name: string, + options: LockOptions | (() => Promise), + callback?: () => Promise, + ) => + new Promise((resolve, reject) => { + const signal = typeof options === "function" ? undefined : options.signal; + const acquired = typeof options === "function" ? options : callback!; + signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); + Queue.offerUnsafe(requested, { + signal, + grant: async () => { + if (!signal?.aborted) resolve(await acquired()); + }, + }); + }), + }, + }); + + const saving = yield* browserThreadTriageOutboxStore.put(queuedSettle).pipe(Effect.forkChild); + const request = yield* Queue.take(requested); + try { + // A broken uninterruptible acquisition must still be released after the assertion. + yield* Effect.sync(() => saving.interruptUnsafe()); + expect(request.signal?.aborted).toBe(true); + } finally { + yield* Effect.promise(request.grant); + yield* Fiber.interrupt(saving); + } + expect(setItem).not.toHaveBeenCalled(); + }), + ); + + it.effect("uses the caller's logger during a locked storage read", () => + Effect.gen(function* () { + const warnings: unknown[] = []; + vi.stubGlobal("window", { + localStorage: { getItem: () => "{invalid", setItem: () => {} }, + }); + vi.stubGlobal("navigator", { + locks: { + request: (_name: string, _options: LockOptions, callback: () => Promise) => + callback(), + }, + }); + + yield* browserThreadTriageOutboxStore + .put(queuedSettle) + .pipe( + Effect.provideService( + Logger.CurrentLoggers, + new Set([Logger.make(({ message }) => warnings.push(message))]), + ), + ); + + expect(warnings).toHaveLength(1); + }), + ); }); diff --git a/apps/web/src/connection/threadTriageOutboxStore.ts b/apps/web/src/connection/threadTriageOutboxStore.ts index 77e894d931e82..5d4152dd544c9 100644 --- a/apps/web/src/connection/threadTriageOutboxStore.ts +++ b/apps/web/src/connection/threadTriageOutboxStore.ts @@ -6,18 +6,6 @@ import * as Effect from "effect/Effect"; const STORAGE_KEY = "t3code:thread-triage-outbox"; -/** Resolves with the release once the lock is granted, so the locked work runs on the caller's fiber. */ -const acquireLock = (locks: LockManager) => - Effect.tryPromise({ - try: () => - new Promise<() => void>((granted, rejected) => { - locks - .request(STORAGE_KEY, () => new Promise((release) => granted(() => release()))) - .catch(rejected); - }), - catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), - }); - /** Kept out of the environment cache so clearing cached data keeps unsynced settles and snoozes. */ export const browserThreadTriageOutboxStore = makeThreadTriageOutboxStore({ read: Effect.try({ @@ -38,8 +26,33 @@ export const browserThreadTriageOutboxStore = makeThreadTriageOutboxStore({ }), ) : Effect.acquireUseRelease( - acquireLock(navigator.locks), - () => effect, - (release) => Effect.sync(release), + // Register cleanup before waiting, so the wait remains interruptible. + Effect.sync(() => ({ + controller: new AbortController(), + release: undefined as (() => void) | undefined, + })), + (lock) => + Effect.tryPromise({ + try: () => + new Promise((granted, rejected) => { + navigator.locks + .request( + STORAGE_KEY, + { signal: lock.controller.signal }, + () => + new Promise((release) => { + lock.release = release; + granted(); + }), + ) + .catch(rejected); + }), + catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), + }).pipe(Effect.andThen(effect)), + (lock) => + Effect.sync(() => { + lock.controller.abort(); + lock.release?.(); + }), ), }); From ca72a3f19ee80ad934274a5a07f5eb0ee0db4d7b Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 25 Oct 2026 18:24:24 +0000 Subject: [PATCH 21/44] fix(web): release queue locks granted after cancellation --- .../threadTriageOutboxStore.test.ts | 86 ++++++++++--------- .../src/connection/threadTriageOutboxStore.ts | 2 + 2 files changed, 48 insertions(+), 40 deletions(-) diff --git a/apps/web/src/connection/threadTriageOutboxStore.test.ts b/apps/web/src/connection/threadTriageOutboxStore.test.ts index 6de54fe871bbc..15b73629edd63 100644 --- a/apps/web/src/connection/threadTriageOutboxStore.test.ts +++ b/apps/web/src/connection/threadTriageOutboxStore.test.ts @@ -109,47 +109,53 @@ describe("browser thread triage storage", () => { }), ); - it.effect("cancels a pending lock request before it can write", () => - Effect.gen(function* () { - const requested = yield* Queue.unbounded<{ - signal: AbortSignal | undefined; - grant: () => Promise; - }>(); - const setItem = vi.fn(); - vi.stubGlobal("window", { localStorage: { getItem: () => null, setItem } }); - vi.stubGlobal("navigator", { - locks: { - request: ( - _name: string, - options: LockOptions | (() => Promise), - callback?: () => Promise, - ) => - new Promise((resolve, reject) => { - const signal = typeof options === "function" ? undefined : options.signal; - const acquired = typeof options === "function" ? options : callback!; - signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); - Queue.offerUnsafe(requested, { - signal, - grant: async () => { - if (!signal?.aborted) resolve(await acquired()); - }, - }); - }), - }, - }); + it.effect.each(["queued", "already granted"] as const)( + "cancels a lock request before it can write (%s)", + (state) => + Effect.gen(function* () { + const requested = yield* Queue.unbounded<{ + signal: AbortSignal | undefined; + grant: () => Promise; + }>(); + const setItem = vi.fn(); + vi.stubGlobal("window", { localStorage: { getItem: () => null, setItem } }); + vi.stubGlobal("navigator", { + locks: { + request: ( + _name: string, + options: LockOptions | (() => Promise), + callback?: () => Promise, + ) => + new Promise((resolve, reject) => { + const signal = typeof options === "function" ? undefined : options.signal; + const acquired = typeof options === "function" ? options : callback!; + if (state === "queued") { + signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); + } + Queue.offerUnsafe(requested, { + signal, + grant: async () => { + if (state === "already granted" || !signal?.aborted) resolve(await acquired()); + }, + }); + }), + }, + }); - const saving = yield* browserThreadTriageOutboxStore.put(queuedSettle).pipe(Effect.forkChild); - const request = yield* Queue.take(requested); - try { - // A broken uninterruptible acquisition must still be released after the assertion. - yield* Effect.sync(() => saving.interruptUnsafe()); - expect(request.signal?.aborted).toBe(true); - } finally { - yield* Effect.promise(request.grant); - yield* Fiber.interrupt(saving); - } - expect(setItem).not.toHaveBeenCalled(); - }), + const saving = yield* browserThreadTriageOutboxStore + .put(queuedSettle) + .pipe(Effect.forkChild); + const request = yield* Queue.take(requested); + try { + // A broken uninterruptible acquisition must still be released after the assertion. + yield* Effect.sync(() => saving.interruptUnsafe()); + expect(request.signal?.aborted).toBe(true); + } finally { + yield* Effect.promise(request.grant); + yield* Fiber.interrupt(saving); + } + expect(setItem).not.toHaveBeenCalled(); + }), ); it.effect("uses the caller's logger during a locked storage read", () => diff --git a/apps/web/src/connection/threadTriageOutboxStore.ts b/apps/web/src/connection/threadTriageOutboxStore.ts index 5d4152dd544c9..c84af2036b05b 100644 --- a/apps/web/src/connection/threadTriageOutboxStore.ts +++ b/apps/web/src/connection/threadTriageOutboxStore.ts @@ -42,6 +42,8 @@ export const browserThreadTriageOutboxStore = makeThreadTriageOutboxStore({ () => new Promise((release) => { lock.release = release; + // Cancellation may finish before a granted lock's callback runs. + if (lock.controller.signal.aborted) release(); granted(); }), ) From ad600b06ebbcdc0c1ac164a2c2e15a383b142efb Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 04:22:15 +0000 Subject: [PATCH 22/44] fix(client): guard offline triage through reconnect and undo --- .../home/useThreadListActions.test.ts | 2 +- .../src/features/home/useThreadListActions.ts | 6 +- apps/web/src/hooks/useThreadActions.ts | 18 ++-- .../src/hooks/useThreadActions.undo.test.ts | 2 +- docs/user/thread-sidebar.md | 3 +- .../src/platform/threadTriageOutboxStore.ts | 39 +++++--- .../src/state/threadCommands.test.ts | 88 ++++++++++++++++++- .../src/state/threadTriageOutbox.ts | 80 ++++++++++++++++- 8 files changed, 208 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 61b1afcd524e9..71e265fb997f3 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -400,7 +400,7 @@ describe("thread action undo", () => { // Whether the queued settle reached the server is known only when Undo runs. it.each([ - ["cancels it before it syncs", { sequence: 0, cancelledPendingSync: true }, []], + ["cancels it before it syncs", { sequence: 0, cancelledPendingSync: true }, ["snooze"]], ["finds it delivered", { sequence: 3 }, ["pin", "snooze"]], ])("restores a queued settle only as far as needed when Undo %s", async (_, undone, restored) => { state.successValues.set("settle", { sequence: 1, pendingSync: true }); diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 91274d358cce7..4118e480e5282 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -281,7 +281,7 @@ export function useThreadListActions(): { action, claim, undo: async () => { - // Cancelling a settle still waiting to sync leaves nothing on the server to restore. + // A local cancellation keeps pin placement, but may have replaced a queued snooze. let cancelledQueued = false; try { await ThreadUndo.executeThreadUndo( @@ -297,7 +297,7 @@ export function useThreadListActions(): { case "unsnooze": return unsnoozeThread(thread); case "pin": - return pinThread(thread, step.orderKey); + return cancelledQueued || pinThread(thread, step.orderKey); case "snooze": { const failureTitle = action === "Unpinned" ? "Could not undo unpin" : "Could not undo settle"; @@ -318,7 +318,7 @@ export function useThreadListActions(): { } } }, - (result) => result && !cancelledQueued, + (result) => result, ); } catch (error) { Dialog.alert( diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index e0e82f22c0ed3..4b0842649d404 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -872,16 +872,22 @@ export function useThreadActions() { return result; }; - const runUndoPlan = (target: ScopedThreadRef, plan: ThreadUndo.ThreadUndoPlan) => - ThreadUndo.executeThreadUndo( + const runUndoPlan = (target: ScopedThreadRef, plan: ThreadUndo.ThreadUndoPlan) => { + let cancelledQueued = false; + return ThreadUndo.executeThreadUndo( plan, async (step) => { switch (step.action) { - case "unsettle": - return unsettleThread(target); + case "unsettle": { + const result = await unsettleThread(target); + cancelledQueued = + result._tag === "Success" && ThreadUndo.cancelledPendingSync(result.value); + return result; + } case "unsnooze": return unsnoozeThread(target); case "pin": + if (cancelledQueued) return AsyncResult.success(undefined); return pinThread( target, step.orderKey === undefined ? {} : { orderKey: step.orderKey }, @@ -893,9 +899,9 @@ export function useThreadActions() { }); } }, - // Cancelling a settle still waiting to sync leaves nothing on the server to restore. - (result) => result._tag === "Success" && !ThreadUndo.cancelledPendingSync(result.value), + (result) => result._tag === "Success", ); + }; const confirmAndDeleteThread = async (target: ScopedThreadRef) => { const permissionFailure = threadOperationFailure(target); diff --git a/apps/web/src/hooks/useThreadActions.undo.test.ts b/apps/web/src/hooks/useThreadActions.undo.test.ts index d81518abfd041..20c065e868a68 100644 --- a/apps/web/src/hooks/useThreadActions.undo.test.ts +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -259,7 +259,7 @@ describe("settle and snooze Undo", () => { input: { threadId: target.threadId, reason: "user" }, }); expect(commands.pin).toHaveBeenCalledTimes(restorations); - expect(commands.snooze).toHaveBeenCalledTimes(restorations); + expect(commands.snooze).toHaveBeenCalledOnce(); if (restorations > 0) { expect(commands.snooze).toHaveBeenCalledWith({ environmentId: target.environmentId, diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 2da302453e807..4b2626ac979ac 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -146,7 +146,8 @@ runs a command, such as a dev server, stays open. You can settle or snooze a thread while its environment is offline. The thread shows **Waiting to sync** on mobile or **Pending** on web and desktop, and the change is sent when the environment reconnects. If the thread changed there in the meantime, -that environment's state wins and your change is dropped. +that environment's state wins and your change is dropped. Undo a pending settle +before snoozing the same thread while offline. On the web, offline changes require a browser with Web Locks support and an HTTPS or localhost connection. diff --git a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts index e10393b56b85c..18bf34554e041 100644 --- a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts +++ b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts @@ -160,17 +160,34 @@ export function makeThreadTriageOutboxStore(storage: { return { load: lock.withPermits(1)(load), put: (entry, stillCurrent) => - update((document) => { - if (stillCurrent?.() === false) return null; - const replaced = document.entries.find((current) => sameThreadTriage(current, entry)); - return { - ...document, - entries: [ - ...document.entries.filter((current) => current !== replaced), - // The replaced change may have reached the server, so the server must undo this one. - replaced?.attempted === true ? { ...entry, attempted: true as const } : entry, - ], - }; + Effect.suspend(() => { + let blocked = false; + return update((document) => { + if (stillCurrent?.() === false) return null; + const replaced = document.entries.find((current) => sameThreadTriage(current, entry)); + if (replaced?.action.type === "settle" && entry.action.type === "snooze") { + blocked = true; + return null; + } + return { + ...document, + entries: [ + ...document.entries.filter((current) => current !== replaced), + // The replaced change may have reached the server, so the server must undo this one. + replaced?.attempted === true ? { ...entry, attempted: true as const } : entry, + ], + }; + }).pipe( + Effect.flatMap(() => + blocked + ? Effect.fail( + new ThreadTriageOutboxStorageError({ + cause: new Error("Undo the pending settle before snoozing while offline."), + }), + ) + : Effect.void, + ), + ); }), remove: (entry) => updateEntries((entries) => diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 306ad9cec48d6..3e03d12fbbbb0 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -578,6 +578,86 @@ describe("settle and snooze while the environment is offline", () => { ? [] : (JSON.parse(storage.value) as { entries: Array> }).entries; + it.effect.each(["settle", "snooze"] as const)( + "guards a %s taken before shell synchronization", + (action) => + Effect.gen(function* () { + const h = yield* makeHarness(); + h.registry.set(h.statusAtom(ENVIRONMENT_ID), "synchronizing"); + const result = h.commands[action].run(h.registry, snooze); + const request = yield* Queue.take(h.requests); + expect(request.command).toMatchObject({ snapshotSequence: 1 }); + yield* Deferred.succeed(request.reply, { sequence: 2 }); + expect((yield* Effect.promise(() => result))._tag).toBe("Success"); + }), + ); + + it.effect("discovers a change another tab queued after this tab loaded", () => + Effect.gen(function* () { + const storage = { value: null as string | null }; + const loaded = yield* Deferred.make(); + const second = yield* makeHarness({ + connected: false, + storage, + wrapStore: (store) => ({ + ...store, + load: store.load.pipe(Effect.tap(() => Deferred.succeed(loaded, undefined))), + }), + }); + yield* Deferred.await(loaded); + const first = yield* makeHarness({ connected: false, storage }); + yield* Effect.promise(() => first.commands.settle.run(first.registry, settle)); + yield* second.reconnect(); + const replayed = yield* Queue.take(second.requests); + expect(replayed.command.type).toBe("thread.settle"); + }), + ); + + it.effect("clears the pending entry after Undo starts during a replay with a lost reply", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false }); + const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); + h.registry.mount(pending); + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + yield* h.reconnect(); + const replay = yield* Queue.take(h.requests); + const undo = h.commands.unsettle.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, reason: "user" }, + }); + yield* Deferred.fail(replay.reply, lostReply()); + const request = yield* Queue.take(h.requests); + yield* Deferred.succeed(request.reply, { sequence: 3 }); + expect((yield* Effect.promise(() => undo))._tag).toBe("Success"); + expect(h.registry.get(pending).size).toBe(0); + expect(stored(h.storage)).toEqual([]); + }), + ); + + it.effect("does not accept an invisible offline settle of a cached running thread", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false }); + h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), { + ...SNAPSHOT, + threads: [{ ...SNAPSHOT.threads[0]!, status: "running" }], + }); + const result = yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + expect(result._tag).toBe("Failure"); + expect(stored(h.storage)).toEqual([]); + }), + ); + + it.effect("keeps a pending settle when snooze would discard it offline", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: false }); + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + const queued = h.storage.value; + const result = yield* Effect.promise(() => h.commands.snooze.run(h.registry, snooze)); + expect(result._tag).toBe("Failure"); + expect(h.storage.value).toBe(queued); + }), + ); + it.effect("applies a settle locally and replays it with the cached version on reconnect", () => Effect.gen(function* () { const h = yield* makeHarness({ connected: false }); @@ -1330,13 +1410,13 @@ describe("settle and snooze while the environment is offline", () => { Effect.gen(function* () { const storage = { value: null as string | null }; const first = yield* makeHarness({ connected: false, storage }); - yield* Effect.promise(() => first.commands.settle.run(first.registry, settle)); - const { tab: second, pending } = yield* otherTab(storage); yield* Effect.promise(() => first.commands.snooze.run(first.registry, snooze)); + const { tab: second, pending } = yield* otherTab(storage); + yield* Effect.promise(() => first.commands.settle.run(first.registry, settle)); const [replacement] = stored(storage); yield* second.reconnect(); expect(yield* sentOrDropped(second, pending)).toMatchObject({ - type: "thread.snooze", + type: "thread.settle", commandId: replacement!.commandId, }); }), @@ -1499,7 +1579,7 @@ describe("settle and snooze while the environment is offline", () => { Effect.gen(function* () { const storage = { value: null as string | null }; const first = yield* makeHarness({ connected: false, storage }); - yield* Effect.promise(() => first.commands.settle.run(first.registry, settle)); + yield* Effect.promise(() => first.commands.snooze.run(first.registry, snooze)); const { tab: second, pending } = yield* otherTab(storage); yield* Effect.promise(() => first.commands.snooze.run(first.registry, snooze)); expireStoredSnooze(storage); diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index 40beadf057e34..1f27ebad84402 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -40,7 +40,7 @@ type Command = AtomCommand< >; type StoreOperation = - | { readonly _tag: "load" } + | { readonly _tag: "load" | "refresh" } | { readonly _tag: "put"; readonly entry: Omit; @@ -125,6 +125,8 @@ export function createThreadTriageOutbox(input: { ) { const outbox = yield* ThreadTriageOutboxStore; switch (operation._tag) { + case "refresh": + return yield* outbox.load; case "load": outbox.subscribeCleared((environmentId) => clearLocal(registry, environmentId)); return yield* outbox.load.pipe( @@ -364,6 +366,50 @@ export function createThreadTriageOutbox(input: { } }; + const reconnectAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + if ( + input.shellStatusAtom === undefined || + get(input.shellStatusAtom(environmentId)) !== "live" + ) + return; + const registry = get.registry; + const lifetime = registry.get(registryLifetimeAtom); + const generation = generations.get(environmentId) ?? 0; + void store.run(registry, { _tag: "refresh" }).then((result) => { + if ( + lifetime.ended || + (generations.get(environmentId) ?? 0) !== generation || + !AsyncResult.isSuccess(result) + ) + return; + // A reconnect also discovers actions queued by a tab that has since closed. + setLocal(registry, (entries) => { + const next = [ + ...entries.filter( + (entry) => entry.environmentId !== environmentId || claimed.has(keyOf(entry)), + ), + ...result.value.entries + .filter( + (entry) => entry.environmentId === environmentId && !claimed.has(keyOf(entry)), + ) + .map( + (entry) => + entries.find( + (current) => + sameThreadTriage(current, entry) && current.commandId === entry.commandId, + ) ?? entry, + ), + ]; + return next.length === entries.length && + next.every((entry, index) => entry === entries[index]) + ? entries + : next; + }); + }); + }), + ); + const replayAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => { const status = input.shellStatusAtom ? get(input.shellStatusAtom(environmentId)) : "empty"; @@ -374,6 +420,7 @@ export function createThreadTriageOutbox(input: { const snapshotAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => { + get.mount(reconnectAtom(environmentId)); get.mount(replayAtom(environmentId)); // Loads the cached config before an offline action needs to read it. if (input.serverConfigAtom) get.mount(input.serverConfigAtom(environmentId)); @@ -424,7 +471,17 @@ export function createThreadTriageOutbox(input: { const superseded = pendingFor(registry, key); try { // Started before any await so the optimistic change shows in the same frame. - const result = await command.run(registry, target); + const result = await command.run( + registry, + snapshot !== null && + input.shellStatusAtom !== undefined && + registry.get(input.shellStatusAtom(environmentId)) !== "live" + ? { + ...target, + input: { ...target.input, snapshotSequence: snapshot.snapshotSequence }, + } + : target, + ); if (AsyncResult.isSuccess(result)) { // The server now has a newer action, which the change it replaced would only fight. // A change queued after this one started stays, and one a replay holds is left to it. @@ -454,6 +511,14 @@ export function createThreadTriageOutbox(input: { } await ensureLoaded(registry); if (snapshot == null || thread === undefined || !current()) return result; + const queuedAction = action(target.input); + const now = DateTime.nowUnsafe(); + if ( + (queuedAction.type === "settle" + ? input.applySettle(thread, now) + : input.applySnooze(thread, queuedAction.snoozedUntil, now)) === thread + ) + return result; // The put replaces any earlier entry for the thread, so a failed save keeps it. const queued = await store.run(registry, { _tag: "put", @@ -505,7 +570,16 @@ export function createThreadTriageOutbox(input: { claimed.has(claimKey) ) { // A claimed entry may already be on its way; the server undoes it after it lands. - return command.run(registry, target); + const result = await command.run(registry, target); + if ( + AsyncResult.isSuccess(result) && + pending !== undefined && + pending.action.type === cancels && + pendingFor(registry, pending) === pending + ) { + await forget(registry, pending); + } + return result; } const lifetime = registry.get(registryLifetimeAtom); claimed.add(claimKey); From 76d097c025ce7a02f637299eacaa72f13420425e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 04:34:56 +0000 Subject: [PATCH 23/44] fix(client): load the full triage outbox before reconnect refresh --- .../src/state/threadCommands.test.ts | 44 +++++++++++++++++++ .../src/state/threadTriageOutbox.ts | 4 +- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 3e03d12fbbbb0..799f4a7c73b3e 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -647,6 +647,50 @@ describe("settle and snooze while the environment is offline", () => { }), ); + it.effect("loads every environment before refreshing an already-live shell", () => + Effect.gen(function* () { + const other = EnvironmentId.make("other"); + const secondStarted = yield* Deferred.make(); + const release = yield* Deferred.make(); + let reads = 0; + const storage = { + value: JSON.stringify({ + entries: [ + { + environmentId: other, + threadId: THREAD_ID, + commandId: "other-settle", + action: { type: "settle" }, + queuedAt: "2026-09-12T10:00:00.000Z", + snapshotSequence: 1, + }, + ], + operableEnvironmentIds: [other], + }), + }; + const h = yield* makeHarness({ + storage, + operateScope: null, + wrapStore: (store) => ({ + ...store, + load: Effect.suspend(() => + ++reads === 2 + ? Deferred.succeed(secondStarted, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(store.load), + ) + : store.load, + ), + }), + }); + yield* Deferred.await(secondStarted); + yield* Deferred.succeed(release, undefined); + const pending = h.commands.pendingSyncAtom(other); + yield* waitFor(h.registry, pending, (threads) => threads.has(THREAD_ID)); + expect(h.registry.get(h.commands.offlineTriagePermittedAtom(other))).toBe(true); + }), + ); + it.effect("keeps a pending settle when snooze would discard it offline", () => Effect.gen(function* () { const h = yield* makeHarness({ connected: false }); diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index 1f27ebad84402..c1ab4d0aa47d7 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -376,7 +376,9 @@ export function createThreadTriageOutbox(input: { const registry = get.registry; const lifetime = registry.get(registryLifetimeAtom); const generation = generations.get(environmentId) ?? 0; - void store.run(registry, { _tag: "refresh" }).then((result) => { + void ensureLoaded(registry).then(async () => { + if (lifetime.ended || (generations.get(environmentId) ?? 0) !== generation) return; + const result = await store.run(registry, { _tag: "refresh" }); if ( lifetime.ended || (generations.get(environmentId) ?? 0) !== generation || From 83d9f4d95f0561ad51e0cf549433ab2dcacd90bd Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 29 Oct 2026 04:35:07 +0000 Subject: [PATCH 24/44] fix(web): queue offline settle and snooze on plain HTTP too The web queue kept its document in localStorage and serialized tabs with a Web Lock, which only exists on secure origins, so a server opened over plain HTTP could not queue at all. The queue now lives in IndexedDB and each change reads and writes in one readwrite transaction, which browsers run in order across tabs on any origin. Co-Authored-By: Claude Opus 5.5 --- .../src/state/thread-triage-outbox-storage.ts | 18 +- .../threadTriageOutboxStore.browser.test.ts | 88 +++++++++ .../threadTriageOutboxStore.test.ts | 186 ------------------ .../src/connection/threadTriageOutboxStore.ts | 126 +++++++----- apps/web/vite.config.ts | 2 + docs/user/thread-sidebar.md | 2 - .../src/platform/threadTriageOutboxStore.ts | 71 +++---- .../src/state/threadCommands.test.ts | 53 ++--- 8 files changed, 231 insertions(+), 315 deletions(-) create mode 100644 apps/web/src/connection/threadTriageOutboxStore.browser.test.ts delete mode 100644 apps/web/src/connection/threadTriageOutboxStore.test.ts diff --git a/apps/mobile/src/state/thread-triage-outbox-storage.ts b/apps/mobile/src/state/thread-triage-outbox-storage.ts index 3321fe8ab94d7..9fc844eef65b4 100644 --- a/apps/mobile/src/state/thread-triage-outbox-storage.ts +++ b/apps/mobile/src/state/thread-triage-outbox-storage.ts @@ -11,18 +11,24 @@ async function getStorageFile() { return new File(Paths.document, "thread-triage-outbox.json"); } +async function readStorageFile() { + const file = await getStorageFile(); + return file.exists ? await file.text() : null; +} + /** Lives outside the client cache so clearing cached data keeps unsynced settles and snoozes. */ export const mobileThreadTriageOutboxStore = makeThreadTriageOutboxStore({ read: Effect.tryPromise({ - try: async () => { - const file = await getStorageFile(); - return file.exists ? await file.text() : null; - }, + try: readStorageFile, catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), }), - write: (value) => + // One process owns this file, and the store already serializes its own changes. + modify: (next) => Effect.tryPromise({ - try: async () => writeFileAtomically(await getStorageFile(), value), + try: async () => { + const value = next(await readStorageFile()); + if (value !== null) await writeFileAtomically(await getStorageFile(), value); + }, catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), }), }); diff --git a/apps/web/src/connection/threadTriageOutboxStore.browser.test.ts b/apps/web/src/connection/threadTriageOutboxStore.browser.test.ts new file mode 100644 index 0000000000000..6fae8f956aebe --- /dev/null +++ b/apps/web/src/connection/threadTriageOutboxStore.browser.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "@effect/vitest"; +import { CommandId, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + makeThreadTriageOutboxStore, + ThreadTriageOutboxStorageError, +} from "@t3tools/client-runtime/platform"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Logger from "effect/Logger"; +import { vi } from "vite-plus/test"; + +import { + browserThreadTriageOutboxStorage, + browserThreadTriageOutboxStore, +} from "./threadTriageOutboxStore"; + +const settle = (threadId: string) => ({ + environmentId: EnvironmentId.make("remote"), + threadId: ThreadId.make(threadId), + commandId: CommandId.make(`settle-${threadId}`), + action: { type: "settle" as const }, + queuedAt: DateTime.makeUnsafe("2026-09-12T10:00:00.000Z"), + snapshotSequence: 1, +}); + +const replaceStored = (raw: string) => browserThreadTriageOutboxStorage.modify(() => raw); + +describe("browser thread triage storage", () => { + it.effect("keeps every tab's change when tabs save at the same time", () => + Effect.gen(function* () { + yield* replaceStored(JSON.stringify({ entries: [], operableEnvironmentIds: [] })); + const tab = () => makeThreadTriageOutboxStore(browserThreadTriageOutboxStorage); + + yield* Effect.all([tab().put(settle("first")), tab().put(settle("second"))], { + concurrency: "unbounded", + }); + + const { entries } = yield* tab().load; + expect(entries.map((entry) => entry.threadId).toSorted()).toEqual(["first", "second"]); + }), + ); + + it.effect("discards an unreadable document on the caller's logger, then saves", () => + Effect.gen(function* () { + yield* replaceStored("{invalid"); + const warnings: unknown[] = []; + + yield* browserThreadTriageOutboxStore + .put(settle("thread")) + .pipe( + Effect.provideService( + Logger.CurrentLoggers, + new Set([Logger.make(({ message }) => warnings.push(message))]), + ), + ); + + expect(warnings).toHaveLength(1); + expect((yield* browserThreadTriageOutboxStore.load).entries).toEqual([settle("thread")]); + }), + ); + + it.effect("clears retained environment state even when storage is unavailable", () => + Effect.gen(function* () { + yield* Effect.acquireRelease( + Effect.sync(() => vi.stubGlobal("indexedDB", undefined)), + () => Effect.sync(() => vi.unstubAllGlobals()), + ); + const environmentId = EnvironmentId.make("removed"); + const retained = new Set([environmentId]); + yield* Effect.acquireRelease( + Effect.sync(() => + browserThreadTriageOutboxStore.subscribeCleared((removed) => retained.delete(removed)), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ); + + const result = yield* Effect.exit( + browserThreadTriageOutboxStore.clearEnvironment(environmentId), + ); + + expect(result._tag === "Failure" && Cause.squash(result.cause)).toBeInstanceOf( + ThreadTriageOutboxStorageError, + ); + expect(retained.size).toBe(0); + }), + ); +}); diff --git a/apps/web/src/connection/threadTriageOutboxStore.test.ts b/apps/web/src/connection/threadTriageOutboxStore.test.ts deleted file mode 100644 index 15b73629edd63..0000000000000 --- a/apps/web/src/connection/threadTriageOutboxStore.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { CommandId, EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { ThreadTriageOutboxStorageError } from "@t3tools/client-runtime/platform"; -import * as Cause from "effect/Cause"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Logger from "effect/Logger"; -import * as Queue from "effect/Queue"; -import { afterEach, vi } from "vite-plus/test"; - -import { browserThreadTriageOutboxStore } from "./threadTriageOutboxStore"; - -const queuedSettle = { - environmentId: EnvironmentId.make("remote"), - threadId: ThreadId.make("thread"), - commandId: CommandId.make("queued-settle"), - action: { type: "settle" as const }, - queuedAt: DateTime.makeUnsafe("2026-09-12T10:00:00.000Z"), - snapshotSequence: 1, -}; - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe("browser thread triage storage", () => { - it.effect.each(["unavailable", "rejected"] as const)( - "refuses to save queued changes when the shared lock is %s", - (lock) => - Effect.gen(function* () { - const setItem = vi.fn(); - vi.stubGlobal("window", { localStorage: { getItem: () => null, setItem } }); - vi.stubGlobal( - "navigator", - lock === "unavailable" - ? {} - : { locks: { request: () => Promise.reject(new Error("Lock request denied")) } }, - ); - - const result = yield* Effect.exit(browserThreadTriageOutboxStore.put(queuedSettle)); - - expect(result._tag).toBe("Failure"); - if (result._tag === "Failure") { - expect(Cause.squash(result.cause)).toBeInstanceOf(ThreadTriageOutboxStorageError); - } - expect(setItem).not.toHaveBeenCalled(); - }), - ); - - it.effect.each(["success", "failure"] as const)( - "releases the acquired lock after storage %s", - (outcome) => - Effect.gen(function* () { - const released = yield* Queue.unbounded(); - let raw: string | null = null; - vi.stubGlobal("window", { - localStorage: { - getItem: () => raw, - setItem: (_key: string, value: string) => { - if (outcome === "failure") throw new Error("Storage write denied"); - raw = value; - }, - }, - }); - vi.stubGlobal("navigator", { - locks: { - request: async ( - _name: string, - _options: LockOptions, - callback: () => Promise, - ) => { - try { - return await callback(); - } finally { - Queue.offerUnsafe(released, undefined); - } - }, - }, - }); - const result = yield* Effect.exit(browserThreadTriageOutboxStore.put(queuedSettle)); - yield* Queue.take(released); - - expect(result._tag).toBe(outcome === "success" ? "Success" : "Failure"); - expect((yield* browserThreadTriageOutboxStore.load).entries).toEqual( - outcome === "success" ? [queuedSettle] : [], - ); - }), - ); - - it.effect("clears retained environment state even when its storage lock is unavailable", () => - Effect.gen(function* () { - vi.stubGlobal("navigator", {}); - const environmentId = EnvironmentId.make("removed"); - const retained = new Set([environmentId]); - yield* Effect.acquireRelease( - Effect.sync(() => - browserThreadTriageOutboxStore.subscribeCleared((removed) => retained.delete(removed)), - ), - (unsubscribe) => Effect.sync(unsubscribe), - ); - - const result = yield* Effect.exit( - browserThreadTriageOutboxStore.clearEnvironment(environmentId), - ); - - expect(result._tag).toBe("Failure"); - expect(retained.size).toBe(0); - }), - ); - - it.effect.each(["queued", "already granted"] as const)( - "cancels a lock request before it can write (%s)", - (state) => - Effect.gen(function* () { - const requested = yield* Queue.unbounded<{ - signal: AbortSignal | undefined; - grant: () => Promise; - }>(); - const setItem = vi.fn(); - vi.stubGlobal("window", { localStorage: { getItem: () => null, setItem } }); - vi.stubGlobal("navigator", { - locks: { - request: ( - _name: string, - options: LockOptions | (() => Promise), - callback?: () => Promise, - ) => - new Promise((resolve, reject) => { - const signal = typeof options === "function" ? undefined : options.signal; - const acquired = typeof options === "function" ? options : callback!; - if (state === "queued") { - signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); - } - Queue.offerUnsafe(requested, { - signal, - grant: async () => { - if (state === "already granted" || !signal?.aborted) resolve(await acquired()); - }, - }); - }), - }, - }); - - const saving = yield* browserThreadTriageOutboxStore - .put(queuedSettle) - .pipe(Effect.forkChild); - const request = yield* Queue.take(requested); - try { - // A broken uninterruptible acquisition must still be released after the assertion. - yield* Effect.sync(() => saving.interruptUnsafe()); - expect(request.signal?.aborted).toBe(true); - } finally { - yield* Effect.promise(request.grant); - yield* Fiber.interrupt(saving); - } - expect(setItem).not.toHaveBeenCalled(); - }), - ); - - it.effect("uses the caller's logger during a locked storage read", () => - Effect.gen(function* () { - const warnings: unknown[] = []; - vi.stubGlobal("window", { - localStorage: { getItem: () => "{invalid", setItem: () => {} }, - }); - vi.stubGlobal("navigator", { - locks: { - request: (_name: string, _options: LockOptions, callback: () => Promise) => - callback(), - }, - }); - - yield* browserThreadTriageOutboxStore - .put(queuedSettle) - .pipe( - Effect.provideService( - Logger.CurrentLoggers, - new Set([Logger.make(({ message }) => warnings.push(message))]), - ), - ); - - expect(warnings).toHaveLength(1); - }), - ); -}); diff --git a/apps/web/src/connection/threadTriageOutboxStore.ts b/apps/web/src/connection/threadTriageOutboxStore.ts index c84af2036b05b..da9852880efe8 100644 --- a/apps/web/src/connection/threadTriageOutboxStore.ts +++ b/apps/web/src/connection/threadTriageOutboxStore.ts @@ -4,57 +4,81 @@ import { } from "@t3tools/client-runtime/platform"; import * as Effect from "effect/Effect"; -const STORAGE_KEY = "t3code:thread-triage-outbox"; +const DATABASE_NAME = "t3code-thread-triage-outbox"; +const STORE_NAME = "outbox"; +const KEY = "document"; -/** Kept out of the environment cache so clearing cached data keeps unsynced settles and snoozes. */ -export const browserThreadTriageOutboxStore = makeThreadTriageOutboxStore({ - read: Effect.try({ - try: () => window.localStorage.getItem(STORAGE_KEY), - catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), +const storageError = (cause: unknown) => new ThreadTriageOutboxStorageError({ cause }); + +const openDatabase = Effect.callback((resume) => { + try { + const request = indexedDB.open(DATABASE_NAME, 1); + request.addEventListener("upgradeneeded", () => request.result.createObjectStore(STORE_NAME)); + request.addEventListener("error", () => resume(Effect.fail(storageError(request.error)))); + request.addEventListener("success", () => resume(Effect.succeed(request.result))); + } catch (cause) { + resume(Effect.fail(storageError(cause))); + } +}); + +/** Succeeds once the transaction commits; `abort` rolls it back and fails with that cause. */ +const transact = ( + mode: IDBTransactionMode, + run: (store: IDBObjectStore, abort: (cause: unknown) => void) => void, +) => + Effect.acquireUseRelease( + openDatabase, + (database) => + Effect.callback((resume) => { + let failure: unknown; + try { + const transaction = database.transaction(STORE_NAME, mode); + transaction.addEventListener("complete", () => resume(Effect.void)); + transaction.addEventListener("abort", () => + resume(Effect.fail(storageError(failure ?? transaction.error))), + ); + run(transaction.objectStore(STORE_NAME), (cause) => { + failure = cause; + transaction.abort(); + }); + } catch (cause) { + resume(Effect.fail(storageError(cause))); + } + }), + (database) => Effect.sync(() => database.close()), + ); + +const stored = (value: unknown) => (typeof value === "string" ? value : null); + +/** + * Kept out of the environment cache so clearing cached data keeps unsynced settles and snoozes. + * Browsers run readwrite transactions on one store in order across tabs, so concurrent changes + * from several tabs all land, on plain HTTP as well as secure origins. + */ +export const browserThreadTriageOutboxStorage = { + read: Effect.suspend(() => { + let value: string | null = null; + return transact("readonly", (store) => { + const request = store.get(KEY); + request.addEventListener("success", () => { + value = stored(request.result); + }); + }).pipe(Effect.map(() => value)); }), - write: (value) => - Effect.try({ - try: () => window.localStorage.setItem(STORAGE_KEY, value), - catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), + modify: (next) => + transact("readwrite", (store, abort) => { + const request = store.get(KEY); + request.addEventListener("success", () => { + try { + const value = next(stored(request.result)); + if (value !== null) store.put(value, KEY); + } catch (cause) { + abort(cause); + } + }); }), - // Reject writes without cross-tab exclusion so shared localStorage changes cannot be lost. - exclusive: (effect) => - typeof navigator === "undefined" || navigator.locks === undefined - ? Effect.fail( - new ThreadTriageOutboxStorageError({ - cause: new Error("Offline thread changes require Web Locks. Use HTTPS or localhost."), - }), - ) - : Effect.acquireUseRelease( - // Register cleanup before waiting, so the wait remains interruptible. - Effect.sync(() => ({ - controller: new AbortController(), - release: undefined as (() => void) | undefined, - })), - (lock) => - Effect.tryPromise({ - try: () => - new Promise((granted, rejected) => { - navigator.locks - .request( - STORAGE_KEY, - { signal: lock.controller.signal }, - () => - new Promise((release) => { - lock.release = release; - // Cancellation may finish before a granted lock's callback runs. - if (lock.controller.signal.aborted) release(); - granted(); - }), - ) - .catch(rejected); - }), - catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), - }).pipe(Effect.andThen(effect)), - (lock) => - Effect.sync(() => { - lock.controller.abort(); - lock.release?.(); - }), - ), -}); +} satisfies Parameters[0]; + +export const browserThreadTriageOutboxStore = makeThreadTriageOutboxStore( + browserThreadTriageOutboxStorage, +); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 501bac5dba020..eaa2f315bdc95 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -120,6 +120,8 @@ const browserTestProject = { "pdfjs-dist", "sql.js", "tiff", + "@effect/vitest", + "effect/Logger", ], }, test: { diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index c986715d21c45..b687d7298063b 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -164,8 +164,6 @@ shows **Waiting to sync** on mobile or **Pending** on web and desktop, and the c is sent when the environment reconnects. If the thread changed there in the meantime, that environment's state wins and your change is dropped. Undo a pending settle before snoozing the same thread while offline. -On the web, offline changes require a browser with Web Locks support and an HTTPS -or localhost connection. To reclaim disk space from settled work, turn on **Run in the thread's worktree when the thread settles** for one of the project's actions, or set `"runOnSettle": true` on a diff --git a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts index 18bf34554e041..6ce051ff2178d 100644 --- a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts +++ b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts @@ -1,6 +1,7 @@ import { CommandId, EnvironmentId, NonNegativeInt, ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -29,8 +30,8 @@ export type ThreadTriageOutboxDocument = typeof ThreadTriageOutboxDocument.Type; const EMPTY_DOCUMENT: ThreadTriageOutboxDocument = { entries: [], operableEnvironmentIds: [] }; const StoredDocument = Schema.fromJsonString(ThreadTriageOutboxDocument); -const decodeStored = Schema.decodeUnknownEffect(StoredDocument); -const encodeStored = Schema.encodeEffect(StoredDocument); +const decodeStored = Schema.decodeUnknownExit(StoredDocument); +const encodeStored = Schema.encodeSync(StoredDocument); export class ThreadTriageOutboxStorageError extends Schema.TaggedError()( "ThreadTriageOutboxStorageError", @@ -101,9 +102,12 @@ export class ThreadTriageOutboxStore extends Context.Reference value), - write: (next) => - Effect.sync(() => { - value = next; + modify: (next) => + Effect.try({ + try: () => { + value = next(value) ?? value; + }, + catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), }), }); }, @@ -112,26 +116,26 @@ export class ThreadTriageOutboxStore extends Context.Reference; - readonly write: (value: string) => Effect.Effect; - /** Serializes each read-modify-write with other processes sharing the storage, such as tabs. */ - readonly exclusive?: ( - effect: Effect.Effect, - ) => Effect.Effect; + /** + * Replaces the stored value with `next(stored)`, or keeps it when that is null, in one step + * that other processes sharing the storage, such as tabs, cannot interleave. + */ + readonly modify: ( + next: (stored: string | null) => string | null, + ) => Effect.Effect; }): ThreadTriageOutboxStoreShape { - const exclusive = storage.exclusive ?? ((effect) => effect); const lock = Semaphore.makeUnsafe(1); const clearedListeners = new Set<(environmentId: EnvironmentId) => void>(); + const parse = (raw: string | null) => + raw === null ? Exit.succeed(EMPTY_DOCUMENT) : decodeStored(raw); + const discardUnreadable = (cause: unknown) => + Effect.logWarning("Discarding unreadable offline thread changes.", { cause: String(cause) }); const load = storage.read.pipe( Effect.flatMap((raw) => - raw === null - ? Effect.succeed(EMPTY_DOCUMENT) - : decodeStored(raw).pipe( - Effect.catch((cause) => - Effect.logWarning("Discarding unreadable offline thread changes.", { - cause: String(cause), - }).pipe(Effect.as(EMPTY_DOCUMENT)), - ), - ), + Exit.match(parse(raw), { + onSuccess: Effect.succeed, + onFailure: (cause) => discardUnreadable(cause).pipe(Effect.as(EMPTY_DOCUMENT)), + }), ), ); /** A null transform skips the write. `after` still runs inside the lock. */ @@ -140,19 +144,20 @@ export function makeThreadTriageOutboxStore(storage: { after: Effect.Effect = Effect.void, ) => lock.withPermits(1)( - exclusive( - load.pipe( - Effect.flatMap((document) => { - const next = transform(document); - return next === null - ? Effect.void - : encodeStored(next).pipe( - Effect.mapError((cause) => new ThreadTriageOutboxStorageError({ cause })), - Effect.flatMap(storage.write), - ); - }), - ), - ).pipe(Effect.ensuring(after)), + Effect.suspend(() => { + let parsed = parse(null); + return storage + .modify((raw) => { + parsed = parse(raw); + const next = transform(Exit.isSuccess(parsed) ? parsed.value : EMPTY_DOCUMENT); + return next === null ? null : encodeStored(next); + }) + .pipe( + Effect.tap(() => + Exit.isFailure(parsed) ? discardUnreadable(parsed.cause) : Effect.void, + ), + ); + }).pipe(Effect.ensuring(after)), ); const updateEntries = ( transform: (entries: ReadonlyArray) => ReadonlyArray, diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index a5229b9f3d80a..0509ecf1e95d2 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -24,7 +24,6 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom, AtomRegistry } from "effect/reactivity"; import { RpcClientError } from "effect/rpc"; @@ -155,13 +154,22 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio ? Effect.fail(new ThreadTriageOutboxStorageError({ cause: "unreadable" })) : Effect.succeed(storage.value); }), - write: (value) => - storage.failWrites - ? Effect.fail(new ThreadTriageOutboxStorageError({ cause: "disk full" })) - : Effect.sync(() => { - storage.value = value; - if (storage.writes) Queue.offerUnsafe(storage.writes, value); - }), + modify: (next) => + Effect.suspend(() => { + if (storage.reads) Queue.offerUnsafe(storage.reads, undefined); + if (storage.failReads) { + return Effect.fail(new ThreadTriageOutboxStorageError({ cause: "unreadable" })); + } + const value = next(storage.value); + if (value === null) return Effect.void; + if (storage.failWrites) { + return Effect.fail(new ThreadTriageOutboxStorageError({ cause: "disk full" })); + } + return Effect.sync(() => { + storage.value = value; + if (storage.writes) Queue.offerUnsafe(storage.writes, value); + }); + }), }); const runtime = Atom.runtime( Layer.mergeAll( @@ -863,35 +871,6 @@ describe("settle and snooze while the environment is offline", () => { }), ); - it.effect("keeps every tab's change when a shared lock serializes their writes", () => - Effect.gen(function* () { - const shared = { value: null as string | null }; - const crossTab = Semaphore.makeUnsafe(1); - // Yields after reading, as another process's write can land before this one writes. - const makeTab = () => - makeThreadTriageOutboxStore({ - read: Effect.sync(() => shared.value).pipe(Effect.tap(() => Effect.yieldNow)), - write: (value) => - Effect.sync(() => { - shared.value = value; - }), - exclusive: (effect) => crossTab.withPermits(1)(effect), - }); - const change = (threadId: string) => ({ - environmentId: ENVIRONMENT_ID, - threadId: ThreadId.make(threadId), - commandId: CommandId.make(`command-${threadId}`), - action: { type: "settle" as const }, - queuedAt: NOW, - snapshotSequence: 1, - }); - yield* Effect.all([makeTab().put(change("first")), makeTab().put(change("second"))], { - concurrency: "unbounded", - }); - expect(stored(shared).map((entry) => entry.threadId)).toEqual(["first", "second"]); - }), - ); - it.effect("restores a queued snooze after a restart and replays it once", () => Effect.gen(function* () { const storage = { value: null as string | null }; From 5bc76a9e72e8def87cc7f3940fcf384c42afd4a4 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 29 Oct 2026 05:01:44 +0000 Subject: [PATCH 25/44] refactor(web): build the outbox storage error where it fails Co-Authored-By: Claude Opus 5.5 --- .../src/connection/threadTriageOutboxStore.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/web/src/connection/threadTriageOutboxStore.ts b/apps/web/src/connection/threadTriageOutboxStore.ts index da9852880efe8..15cc219b18f6d 100644 --- a/apps/web/src/connection/threadTriageOutboxStore.ts +++ b/apps/web/src/connection/threadTriageOutboxStore.ts @@ -8,16 +8,16 @@ const DATABASE_NAME = "t3code-thread-triage-outbox"; const STORE_NAME = "outbox"; const KEY = "document"; -const storageError = (cause: unknown) => new ThreadTriageOutboxStorageError({ cause }); - const openDatabase = Effect.callback((resume) => { try { const request = indexedDB.open(DATABASE_NAME, 1); request.addEventListener("upgradeneeded", () => request.result.createObjectStore(STORE_NAME)); - request.addEventListener("error", () => resume(Effect.fail(storageError(request.error)))); + request.addEventListener("error", () => + resume(Effect.fail(new ThreadTriageOutboxStorageError({ cause: request.error }))), + ); request.addEventListener("success", () => resume(Effect.succeed(request.result))); } catch (cause) { - resume(Effect.fail(storageError(cause))); + resume(Effect.fail(new ThreadTriageOutboxStorageError({ cause }))); } }); @@ -35,14 +35,18 @@ const transact = ( const transaction = database.transaction(STORE_NAME, mode); transaction.addEventListener("complete", () => resume(Effect.void)); transaction.addEventListener("abort", () => - resume(Effect.fail(storageError(failure ?? transaction.error))), + resume( + Effect.fail( + new ThreadTriageOutboxStorageError({ cause: failure ?? transaction.error }), + ), + ), ); run(transaction.objectStore(STORE_NAME), (cause) => { failure = cause; transaction.abort(); }); } catch (cause) { - resume(Effect.fail(storageError(cause))); + resume(Effect.fail(new ThreadTriageOutboxStorageError({ cause }))); } }), (database) => Effect.sync(() => database.close()), From 2d21e1f34809a2e5ce33f3a7503f3a74b0378d82 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 29 Oct 2026 05:26:47 +0000 Subject: [PATCH 26/44] fix(server): a stopped provider session no longer drops queued settle and snooze Shutting a server down, and recovering after it lost power, marks every live provider session stopped. That event is newer than the snapshot a client queued its offline settle or snooze against, so the version guard rejected the replay and the user's change was silently lost, which is the issue's own case of a machine switched off. The guard now ignores provider session updates; turns, messages and triage changes still win over a stale queued action. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/EventSink.ts | 1 + .../server/src/orchestration-v2/EventStore.ts | 21 ++++++----- .../src/orchestration-v2/Orchestrator.ts | 6 +++- .../src/orchestration-v2/runtimeLayer.test.ts | 35 +++++++++++++++++++ .../persistence/OrchestrationEventStore.ts | 3 ++ packages/contracts/src/orchestrationV2.ts | 4 +-- 6 files changed, 58 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index b210d3c9840ae..afab7f237fd67 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -190,6 +190,7 @@ export interface EventSinkV2Shape { }) => Stream.Stream; readonly latestSequence: (input?: { readonly threadId?: ThreadId; + readonly exceptEventTypes?: ReadonlyArray; }) => Effect.Effect; readonly readByCommandId: (input: { readonly commandId: CommandId; diff --git a/apps/server/src/orchestration-v2/EventStore.ts b/apps/server/src/orchestration-v2/EventStore.ts index e2125508dee8f..bfbcb7b824536 100644 --- a/apps/server/src/orchestration-v2/EventStore.ts +++ b/apps/server/src/orchestration-v2/EventStore.ts @@ -67,6 +67,7 @@ export interface EventStoreV2Shape { }) => Stream.Stream; readonly latestSequence: (input?: { readonly threadId?: ThreadId; + readonly exceptEventTypes?: ReadonlyArray; }) => Effect.Effect; readonly latestApplicationSequence: Effect.Effect; readonly publishCommitted: (events: ReadonlyArray) => Effect.Effect; @@ -131,15 +132,17 @@ const layerBase: Layer.Layer new EventStoreReadEventsError({ cause }))), latestSequence: Effect.fn("EventStoreV2.latestSequence")(function (input) { - return applicationEvents.latestAgentSequence(input?.threadId).pipe( - Effect.mapError( - (cause) => - new EventStoreReadEventsError({ - ...(input?.threadId === undefined ? {} : { threadId: input.threadId }), - cause, - }), - ), - ); + return applicationEvents + .latestAgentSequence(input?.threadId, input?.exceptEventTypes) + .pipe( + Effect.mapError( + (cause) => + new EventStoreReadEventsError({ + ...(input?.threadId === undefined ? {} : { threadId: input.threadId }), + cause, + }), + ), + ); }), latestApplicationSequence: applicationEvents.latestApplicationSequence.pipe( Effect.mapError((cause) => new EventStoreReadEventsError({ cause })), diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 7cf65f352ff4d..109f6392acae8 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3022,7 +3022,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command.snapshotSequence !== undefined ) { const currentSequence = yield* eventSink - .latestSequence({ threadId: command.threadId }) + .latestSequence({ + threadId: command.threadId, + // Shutdown and restart recovery stop every live session; the user changed nothing. + exceptEventTypes: ["provider-session.updated"], + }) .pipe( Effect.mapError( (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 39d48bd2ab863..4bd8226a94751 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -5908,6 +5908,7 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => { Effect.gen(function* () { const projects = yield* ProjectService.ProjectService; const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; const projectId = ProjectId.make("runtime-layer-triage-version-project"); const threadId = ThreadId.make("runtime-layer-triage-version-thread"); const snoozedUntil = "2099-07-25T09:00:00.000Z"; @@ -5976,7 +5977,41 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => { assert.isNull(untouched.settledOverride); assert.isNull(untouched.snoozedUntil ?? null); + // Shutdown and restart recovery stop live sessions; that is not a change the user made. + const now = yield* DateTime.now; + const session = { + id: ProviderSessionId.make("runtime-layer-triage-version-session"), + driver, + providerInstanceId: modelSelection.instanceId, + status: "ready" as const, + cwd: "/tmp/runtime-layer-triage-version-project", + model: modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }; + const sessionEvent = (type: "provider-session.attached" | "provider-session.updated") => + eventSink.write({ + commandId: CommandId.make(`runtime-layer-triage-version-${type}`), + events: [ + { + id: EventId.make(`runtime-layer-triage-version-${type}`), + type, + threadId, + driver, + providerInstanceId: session.providerInstanceId, + occurredAt: now, + payload: + type === "provider-session.attached" + ? session + : { ...session, status: "stopped" }, + }, + ], + }); + yield* sessionEvent("provider-session.attached"); const current = yield* versionOf(); + yield* sessionEvent("provider-session.updated"); const settle = { type: "thread.settle" as const, commandId: CommandId.make("runtime-layer-triage-version-settle"), diff --git a/apps/server/src/persistence/OrchestrationEventStore.ts b/apps/server/src/persistence/OrchestrationEventStore.ts index 51c3861a9fca0..832d5ef457833 100644 --- a/apps/server/src/persistence/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/OrchestrationEventStore.ts @@ -89,6 +89,7 @@ export class OrchestrationEventStore extends Context.Service< readonly latestAgentSequence: ( threadId?: ThreadId, + exceptEventTypes?: ReadonlyArray, ) => Effect.Effect; readonly latestApplicationSequence: Effect.Effect; @@ -639,6 +640,7 @@ const makeEventStore = Effect.gen(function* () { const latestAgentSequence: OrchestrationEventStore["Service"]["latestAgentSequence"] = ( threadId, + exceptEventTypes = [], ) => sql<{ readonly sequence: number | null }>` SELECT MAX(sequence) AS sequence @@ -651,6 +653,7 @@ const makeEventStore = Effect.gen(function* () { WHERE application_event_version = 2 AND aggregate_kind = 'thread' ${threadId === undefined ? sql`` : sql`AND stream_id = ${threadId}`} + ${exceptEventTypes.length === 0 ? sql`` : sql`AND event_type NOT IN ${sql.in(exceptEventTypes)}`} `.pipe( NodeSqliteClient.readOnly, Effect.map((rows) => rows[0]?.sequence ?? 0), diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 742d2c1c917f0..afe44cb97bc6a 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -2908,7 +2908,7 @@ export const OrchestrationV2Command = Schema.Union([ commandId: CommandId, threadId: ThreadId, settledAt: Schema.optional(Schema.DateTimeUtc), - /** Rejects the command if the thread has any event after this shell sequence. */ + /** Rejects the command if the thread changed after this shell sequence; a session stop does not count. */ snapshotSequence: Schema.optional(NonNegativeInt), }), /** Rejects changes since snapshotAt or any explicit settled override to preserve user actions. */ @@ -2930,7 +2930,7 @@ export const OrchestrationV2Command = Schema.Union([ commandId: CommandId, threadId: ThreadId, snoozedUntil: IsoDateTime, - /** Rejects the command if the thread has any event after this shell sequence. */ + /** Rejects the command if the thread changed after this shell sequence; a session stop does not count. */ snapshotSequence: Schema.optional(NonNegativeInt), }), Schema.Struct({ From 55c8f9814a55b7686cde42ca538ac8d4dd4c91cf Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 29 Oct 2026 12:22:33 +0000 Subject: [PATCH 27/44] fix(web): Pending outranks a folder row's status and wake label A queued snooze whose wake time passes offline, or a settle queued for a thread the client still shows as busy, leaves the row in its folder. There the status (Done, Working) took the time slot and hid that the change is waiting to sync. Co-Authored-By: Claude Opus 5.5 --- .../components/sidebar/SidebarThreadRow.tsx | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index c83ff67d052db..b83006f3e1cc5 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -135,18 +135,19 @@ function compactSidebarTimeLabel(label: string): string { return label.endsWith(" ago") ? label.slice(0, -4) : label; } +function PendingSyncLabel() { + return ( + + }>Pending + Waiting to sync with its environment + + ); +} + // Subscribes to the minute tick itself, so only the label re-renders each // minute. The minute is passed as the clock so it is the formatter's input. -function RelativeTimeLabel(props: { timestamp: string | null; pendingSync?: boolean }) { +function RelativeTimeLabel(props: { timestamp: string | null }) { const nowMinute = useNowMinute(); - if (props.pendingSync) { - return ( - - }>Pending - Waiting to sync with its environment - - ); - } return props.timestamp === null ? null : compactSidebarTimeLabel( @@ -682,7 +683,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP if (row !== null) revealRef?.(row, threadKey); return leaseRowRef(row); }; - const isRegeneratingTitle = thread.titleRegeneration != null; const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localLastVisitedAt); @@ -1296,9 +1296,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP !isWoke && "group-any-hover/sidebar-row:opacity-0", )} > - {!pendingSync && - variantAction === "unsnooze" && - props.snoozeWakeLabelText !== null ? ( + {pendingSync ? ( + + + + ) : variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( {/* The clock tells a wake time from an age at a glance. */} @@ -1335,7 +1337,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : ( - {topStatus ? ( + {pendingSync ? ( + + ) : topStatus ? ( isWokeStatus ? ( ) ) : ( - + )} {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? ( From 24659c5e8bf36a6fbbd369cd896e328e22148660 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 29 Oct 2026 12:49:20 +0000 Subject: [PATCH 28/44] fix(client): settle and snooze during reconnect still work against older servers An action taken while the shell was still synchronizing always carried the snapshot sequence, so a connected server without the version guard refused it as too old. The sequence is now attached only when the server advertises the guard. The user guide also states what offline settle and snooze need. Co-Authored-By: Claude Opus 5.5 --- docs/user/thread-sidebar.md | 12 +++++++----- .../src/state/threadCommands.test.ts | 16 +++++++++++----- .../src/state/threadTriageOutbox.ts | 9 +++++---- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 4146aa5b663f7..140da029a9412 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -165,11 +165,13 @@ sending an answer or restarting the agent. Settling also closes the thread's terminals that wait at an idle prompt, and keeps their output. A terminal that runs a command, such as a dev server, stays open. -You can settle or snooze a thread while its environment is offline. The thread -shows **Waiting to sync** on mobile or **Pending** on web and desktop, and the change -is sent when the environment reconnects. If the thread changed there in the meantime, -that environment's state wins and your change is dropped. Undo a pending settle -before snoozing the same thread while offline. +You can settle or snooze a thread while its environment is offline, if this device +could change threads there the last time it connected and the environment runs a +release with offline sync. The thread shows **Waiting to sync** on mobile or +**Pending** on web and desktop, and the change is sent when the environment +reconnects. If the thread changed there in the meantime, that environment's state +wins and your change is dropped. Undo a pending settle before snoozing the same +thread while offline. To reclaim disk space from settled work, turn on **Run in the thread's worktree when the thread settles** for one of the project's actions, or set `"runOnSettle": true` on a diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 0509ecf1e95d2..899732daf8508 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -641,15 +641,21 @@ describe("settle and snooze while the environment is offline", () => { ? [] : (JSON.parse(storage.value) as { entries: Array> }).entries; - it.effect.each(["settle", "snooze"] as const)( - "guards a %s taken before shell synchronization", - (action) => + // An older server cannot check the sequence, so the connected command goes out unguarded. + it.effect.each([ + { action: "settle", guarded: true }, + { action: "snooze", guarded: true }, + { action: "settle", guarded: false }, + { action: "snooze", guarded: false }, + ] as const)( + "sends a $action taken before shell synchronization (version guard: $guarded)", + ({ action, guarded }) => Effect.gen(function* () { - const h = yield* makeHarness(); + const h = yield* makeHarness({ versionGuard: guarded, cachedVersionGuard: guarded }); h.registry.set(h.statusAtom(ENVIRONMENT_ID), "synchronizing"); const result = h.commands[action].run(h.registry, snooze); const request = yield* Queue.take(h.requests); - expect(request.command).toMatchObject({ snapshotSequence: 1 }); + expect(Reflect.get(request.command, "snapshotSequence")).toBe(guarded ? 1 : undefined); yield* Deferred.succeed(request.reply, { sequence: 2 }); expect((yield* Effect.promise(() => result))._tag).toBe("Success"); }), diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index c1ab4d0aa47d7..4a0cf8f33a5ed 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -471,11 +471,15 @@ export function createThreadTriageOutbox(input: { const snapshot = registry.get(input.sourceSnapshotAtom(environmentId)); const thread = snapshot?.threads.find((candidate) => candidate.id === key.threadId); const superseded = pendingFor(registry, key); + const guarded = + (input.serverConfigAtom ? registry.get(input.serverConfigAtom(environmentId)) : null) + ?.environment.capabilities.threadTriageVersionGuard === true; try { // Started before any await so the optimistic change shows in the same frame. const result = await command.run( registry, snapshot !== null && + guarded && input.shellStatusAtom !== undefined && registry.get(input.shellStatusAtom(environmentId)) !== "live" ? { @@ -499,10 +503,7 @@ export function createThreadTriageOutbox(input: { } const offline = offlineError(result.cause); if (offline === undefined) return result; - const config = input.serverConfigAtom - ? registry.get(input.serverConfigAtom(environmentId)) - : null; - if (config?.environment.capabilities.threadTriageVersionGuard !== true) { + if (!guarded) { return AsyncResult.failure( Cause.fail( new ThreadVersionGuardUnsupportedError({ From d9d74d01733956c5eeb10b1bb1c5bc9bd1380098 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:50:48 +0000 Subject: [PATCH 29/44] fix(client): say why snooze is refused while a settle waits to sync Snoozing a thread whose settle is still queued offline showed " is not connected." The refusal now says to undo the pending settle first. Co-Authored-By: Claude Opus 5.5 --- .../src/platform/threadTriageOutboxStore.ts | 21 +++++++++++-------- .../src/state/threadCommands.test.ts | 4 +++- .../src/state/threadTriageOutbox.ts | 21 ++++++++++++++----- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts index 6ce051ff2178d..6b98b178fe049 100644 --- a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts +++ b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts @@ -42,6 +42,15 @@ export class ThreadTriageOutboxStorageError extends Schema.TaggedError()( + "ThreadTriagePendingSettleError", + {}, +) { + override get message(): string { + return "Undo the pending settle before snoozing while offline."; + } +} + export type ThreadTriageKey = Pick; export const sameThreadTriage = (a: ThreadTriageKey, b: ThreadTriageKey) => @@ -61,7 +70,7 @@ export interface ThreadTriageOutboxStoreShape { readonly put: ( entry: PendingThreadTriage, stillCurrent?: () => boolean, - ) => Effect.Effect; + ) => Effect.Effect; /** Only removes the entry if it still has this command id. */ readonly remove: ( entry: ThreadTriageKey & Pick, @@ -84,7 +93,7 @@ export interface ThreadTriageOutboxStoreShape { environmentId: EnvironmentId, operable: boolean, stillCurrent?: () => boolean, - ) => Effect.Effect; + ) => Effect.Effect; readonly clearEnvironment: ( environmentId: EnvironmentId, ) => Effect.Effect; @@ -184,13 +193,7 @@ export function makeThreadTriageOutboxStore(storage: { }; }).pipe( Effect.flatMap(() => - blocked - ? Effect.fail( - new ThreadTriageOutboxStorageError({ - cause: new Error("Undo the pending settle before snoozing while offline."), - }), - ) - : Effect.void, + blocked ? Effect.fail(new ThreadTriagePendingSettleError()) : Effect.void, ), ); }), diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 899732daf8508..617a8e3edad72 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -766,7 +766,9 @@ describe("settle and snooze while the environment is offline", () => { yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); const queued = h.storage.value; const result = yield* Effect.promise(() => h.commands.snooze.run(h.registry, snooze)); - expect(result._tag).toBe("Failure"); + expect(result._tag === "Failure" ? String(Cause.squash(result.cause)) : "Success").toContain( + "Undo the pending settle before snoozing while offline.", + ); expect(h.storage.value).toBe(queued); }), ); diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index 4a0cf8f33a5ed..d126455d13ee1 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -20,6 +20,7 @@ import { sameThreadTriage, ThreadTriageOutboxStore, type ThreadTriageOutboxStorageError, + ThreadTriagePendingSettleError, } from "../platform/threadTriageOutboxStore.ts"; import { ThreadVersionGuardUnsupportedError } from "../operations/commands.ts"; import { EnvironmentRpcUnavailableError, isRpcClientError } from "../rpc/client.ts"; @@ -59,6 +60,7 @@ const EMPTY_DOCUMENT: ThreadTriageOutboxDocument = { entries: [], operableEnviro const isUnavailable = Schema.is(EnvironmentRpcUnavailableError); const isGuardUnsupported = Schema.is(ThreadVersionGuardUnsupportedError); +const isPendingSettle = Schema.is(ThreadTriagePendingSettleError); function offlineError(cause: Cause.Cause) { const reason = cause.reasons.find( @@ -144,9 +146,11 @@ export function createThreadTriageOutbox(input: { }; yield* outbox.put(entry, operation.stillCurrent).pipe( Effect.tapError((error) => - Effect.logWarning("Could not save an offline thread change.", { - cause: String(error.cause), - }), + error._tag === "ThreadTriagePendingSettleError" + ? Effect.void + : Effect.logWarning("Could not save an offline thread change.", { + cause: String(error.cause), + }), ), ); return { ...EMPTY_DOCUMENT, entries: [entry] }; @@ -454,7 +458,7 @@ export function createThreadTriageOutbox(input: { function queueWhenOffline( command: Command, action: (input: I) => PendingThreadTriage["action"], - ): Command { + ): Command { return { label: command.label, run: async (registry, target) => { @@ -533,8 +537,15 @@ export function createThreadTriageOutbox(input: { }, stillCurrent: current, }); + if (!AsyncResult.isSuccess(queued)) { + return queued.cause.reasons.some( + (reason) => Cause.isFailReason(reason) && isPendingSettle(reason.error), + ) + ? AsyncResult.failure(Cause.fail(new ThreadTriagePendingSettleError())) + : result; + } // A removal before the write skipped it; one after it already deleted the entry. - if (!AsyncResult.isSuccess(queued) || !current()) return result; + if (!current()) return result; const entry = queued.value.entries[0]!; setLocal(registry, (entries) => [ ...entries.filter((existing) => !sameThreadTriage(existing, entry)), From 2cc00a91fb1e791ba2e24ac1823395bbb94779d8 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:47:29 +0000 Subject: [PATCH 30/44] fix(client): offline, un-settle and wake need a change waiting to sync Offline, Un-settle and Wake now were enabled on threads whose settle or snooze had already synced, then failed as not connected. They are now only offered for a change still waiting to sync, in the web chat header and menus and the mobile thread screen, list menu and swipe actions. Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/ThreadDetailScreen.tsx | 8 ++++---- .../src/features/threads/thread-list-v2-items.tsx | 12 ++++++++---- apps/web/src/components/Sidebar.tsx | 3 ++- apps/web/src/components/chat/useThreadLifecycle.tsx | 7 ++++--- .../src/components/threadActionMenu.logic.test.ts | 13 ++++++++++--- apps/web/src/components/threadActionMenu.logic.ts | 10 ++++++++-- apps/web/src/hooks/useThreadActionMenu.test.ts | 1 + apps/web/src/hooks/useThreadActionMenu.ts | 3 ++- apps/web/src/state/threads.ts | 3 +++ 9 files changed, 42 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 87f4f750c21e4..ffa8ebf255c3a 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -568,11 +568,11 @@ function ThreadDetailBody( }); const unsnoozeThread = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); const unsettleThread = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); - // Offline, wake and un-settle can still cancel or queue where the last session could operate. - const offlineTriagePermitted = useAtomValue( - threadEnvironment.offlineTriagePermittedAtom(props.environmentId), + // Offline, wake and un-settle only cancel a change still waiting to sync. + const pendingSync = useAtomValue(threadEnvironment.pendingSyncAtom(props.environmentId)).has( + props.selectedThread.id, ); - const canTriageThread = props.canOperateThread || offlineTriagePermitted; + const canTriageThread = props.canOperateThread || pendingSync; const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false }); const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const anyRunStop = useAtomValue( diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index bbdf5b9b6cc0d..b948e995ea725 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -259,7 +259,7 @@ type ThreadListV2ShelfHeaderProps = { readonly pane?: "screen" | "sidebar"; }; -const TRIAGE_MENU_ACTION_IDS = new Set(["settle", "snooze", "unsettle", "unsnooze"]); +const TRIAGE_MENU_ACTION_IDS = new Set(["settle", "snooze"]); const SHELF_LABEL = { working: "Working", snoozed: "Snoozed", settled: "Settled" } as const; @@ -779,6 +779,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { threadEnvironment.offlineTriagePermittedAtom(thread.environmentId), ); const triageOnly = !canOperateThread && offlineTriagePermitted; + // Un-settle and wake only cancel a change still waiting to sync. + const triageAllows = (action: string) => + TRIAGE_MENU_ACTION_IDS.has(action) || + (pendingSync && (action === "unsettle" || action === "unsnooze")); const handleRegenerateTitle = useCallback( () => onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, thread], @@ -1108,7 +1112,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [handleMenuAction, snoozePresetActions, swipeActions.secondary, thread.title], ); const swipeAccessibilityHint = - !canOperateThread && !triageOnly + !canOperateThread && !(triageOnly && triageAllows(swipeActions.primary)) ? "Opens the thread" : secondaryAction === null ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.` @@ -1470,7 +1474,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); - if (!canOperateThread && !(triageOnly && swipeActions.primary !== "archive")) + if (!canOperateThread && !(triageOnly && triageAllows(swipeActions.primary))) return {rowContent(() => {})}; const menuActions: MenuAction[] = [ ...(thread.branch @@ -1522,7 +1526,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { TRIAGE_MENU_ACTION_IDS.has(action.id ?? "")) + ? menuActions.filter((action) => triageAllows(action.id ?? "")) : menuActions } onPressAction={handleMenuAction} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 507264af03678..cd38787c9c653 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -154,7 +154,7 @@ import { import { environmentPresentations } from "../state/presentation"; import { primaryServerKeybindingsAtom } from "../state/server"; import { environmentProjects } from "../state/projects"; -import { readThreadTriageScope, threadEnvironment } from "../state/threads"; +import { readThreadPendingSync, readThreadTriageScope, threadEnvironment } from "../state/threads"; import { useOlderSettledShelf, useOlderSettledShelfGrowth } from "../state/olderSettledShelf"; import { useThreadSearch } from "../state/queries"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; @@ -2639,6 +2639,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve canTriageOffline: appAtomRegistry.get( threadEnvironment.offlineTriagePermittedAtom(threadRef.environmentId), ), + hasPendingSync: readThreadPendingSync(threadRef.environmentId, threadRef.threadId), branch: thread.branch ?? null, // An archived project is not a filter choice. projectFilter: diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index fa9af2bd27b25..41df88418514d 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -176,9 +176,10 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { const router = useRouter(); const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); - // Offline, wake and un-settle can still cancel or queue where the last session could operate. + const pendingSync = useAtomValue(threadEnvironment.pendingSyncAtom(environmentId)); + // Offline, wake and un-settle only cancel a change still waiting to sync. const canTriageThread = - useAtomValue(threadEnvironment.offlineTriagePermittedAtom(environmentId)) || canOperateThread; + canOperateThread || (activeThreadId !== null && pendingSync.has(activeThreadId)); const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, @@ -651,7 +652,7 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { if (!readThreadTriageScope(environmentId)) return false; if (!activeThreadShell || !activeThreadRef || !serverConfigLoaded) return; if (activeThreadSettled) { - void handleUnsettleActiveThread(); + if (canTriageThread) void handleUnsettleActiveThread(); return; } if (settleAndAdvance(activeThreadRef)) return; diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 377153b1f3407..dd0a92da59085 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -65,19 +65,26 @@ describe("buildThreadActionMenuItems", () => { }, ); - it.each([false, true])( - "keeps only settle and snooze directions offline where triage is remembered (reversed: %s)", - (reversed) => { + // Offline, un-settle and wake can only cancel a change still waiting to sync. + it.each([ + [false, false], + [true, false], + [true, true], + ])( + "keeps only settle and snooze directions offline where triage is remembered (reversed: %s, pending: %s)", + (reversed, pending) => { const items = buildThreadActionMenuItems({ ...baseState, canOperate: false, canTriageOffline: true, + hasPendingSync: pending, isPinned: reversed, isSettled: reversed, isSnoozed: reversed, }); expect(items.filter((item) => item.disabled).map((item) => item.id)).toEqual([ reversed ? "unpin" : "pin", + ...(reversed && !pending ? ["unsettle", "unsnooze"] : []), "rename", "regenerate-title", "auto-settle", diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index ce04fe1ab9757..2274ceebbd066 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -69,8 +69,10 @@ export function buildDraftActionMenuItems(options: { export interface ThreadActionMenuState { readonly canOperate: boolean; - /** Offline, settle, snooze and their undo can queue where the last session could operate. */ + /** Offline, settle and snooze can queue where the last session could operate. */ readonly canTriageOffline?: boolean; + /** Offline, un-settle and wake only cancel a change still waiting to sync. */ + readonly hasPendingSync?: boolean; readonly canFind?: boolean; readonly branch: string | null; readonly projectFilter: { @@ -268,7 +270,11 @@ export function buildThreadActionMenuItems( ? items : items.map((item) => threadActionRequiresOperate(item.id) && - !(state.canTriageOffline === true && isThreadTriageAction(item.id)) + !( + state.canTriageOffline === true && + isThreadTriageAction(item.id) && + (state.hasPendingSync === true || (item.id !== "unsettle" && item.id !== "unsnooze")) + ) ? { ...item, disabled: true, diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts index 2215c22f52f00..7ad29f23247f3 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -65,6 +65,7 @@ vi.mock("../state/environments", () => ({ usePrimaryEnvironmentId: () => "primar vi.mock("../state/threads", () => ({ // No remembered offline permission here, so triage follows the live grant. readThreadTriageScope: (environmentId: string) => state.granted.has(environmentId), + readThreadPendingSync: () => false, threadEnvironment: { updateMetadata: "metadata" }, })); vi.mock("../state/use-atom-command", () => ({ diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index ccc49c79b4b9a..32e35429a5dcd 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -25,7 +25,7 @@ import { import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { copyThreadReference } from "../lib/copyThreadReference"; import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; -import { readThreadTriageScope, threadEnvironment } from "../state/threads"; +import { readThreadPendingSync, readThreadTriageScope, threadEnvironment } from "../state/threads"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { readEnvironmentScope } from "../state/session"; import { @@ -136,6 +136,7 @@ export function useThreadActionMenu(input: { const items = buildThreadActionMenuItems({ canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), canTriageOffline: readThreadTriageScope(threadRef.environmentId), + hasPendingSync: readThreadPendingSync(threadRef.environmentId, threadRef.threadId), canFind: onFind !== undefined, branch: thread.branch ?? null, projectFilter: null, diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index 280843311d4e3..ee8fbcff5f8d9 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -60,6 +60,9 @@ export function readThreadTriageScope(environmentId: EnvironmentId): boolean { appAtomRegistry.get(threadEnvironment.offlineTriagePermittedAtom(environmentId)) ); } +export function readThreadPendingSync(environmentId: EnvironmentId, threadId: ThreadId): boolean { + return appAtomRegistry.get(threadEnvironment.pendingSyncAtom(environmentId)).has(threadId); +} const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( environmentThreads.stateAtom, From 22139fb924116d9a7bb1846bc881d2beff116e70 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:51:21 +0000 Subject: [PATCH 31/44] fix(mobile): keep the environment-cleanup error catch after merging main Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/connection/platform.ts | 7 +++++++ packages/client-runtime/src/state/threadCommands.ts | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index e85b18ab51f7f..d24248e3a2904 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -268,6 +268,13 @@ const layerEnvironmentOwnedDataCleanup = Layer.succeed( mobileThreadTriageOutboxStore.clearEnvironment(environmentId), ], { concurrency: "unbounded", discard: true }, + ).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not clear mobile environment-owned data.", { + environmentId, + cause, + }), + ), ), ), }), diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 2b3105c09f6c0..65dcc0ad5ec98 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -102,7 +102,8 @@ function applySnooze( accepted: boolean, ): OrchestrationV2ThreadShell { return (!accepted && - ((thread.pendingRuntimeRequest !== null && thread.pendingRuntimeRequest.kind !== "user_input") || + ((thread.pendingRuntimeRequest !== null && + thread.pendingRuntimeRequest.kind !== "user_input") || ["preparing", "queued", "starting"].includes(thread.status))) || !(Date.parse(snoozedUntil) > DateTime.toEpochMillis(now)) ? thread From f3ba024b49912755a6d79cb701c64f9e334d66c3 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:57:18 +0000 Subject: [PATCH 32/44] refactor(client-runtime): simplify the offline triage queue Replay now re-reads the stored queue right before it sends, so a change another tab queued, replaced or cancelled is handled by one rule instead of cross-tab merging, per-thread claims and removal-race generations. The server's snapshot guard and command-id dedupe cover stale and duplicate replays. Offline settle and snooze stay available until a loaded session denies them, and the server checks the scope again on replay, so the queue no longer remembers which environments could operate. Undo keeps one rule: a sent change is undone on the server, an unsent one is cancelled locally. Drops the tests for the removed machinery and the browser test that needed the vite config change. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/state/threads.ts | 8 - .../threadTriageOutboxStore.browser.test.ts | 22 +- apps/web/src/state/threads.ts | 10 +- apps/web/vite.config.ts | 2 - docs/user/thread-sidebar.md | 8 +- .../src/platform/threadTriageOutboxStore.ts | 69 +-- .../src/state/threadCommands.test.ts | 552 +----------------- .../src/state/threadCommands.ts | 15 +- .../src/state/threadTriageOutbox.ts | 520 ++++++----------- 9 files changed, 217 insertions(+), 989 deletions(-) diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 1f69459e33abb..40d2441dd15a5 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -34,13 +34,6 @@ export const olderSettledThreads = createOlderSettledThreadAtoms( connectionAtomRuntime, environmentShell.stateAtom, ); -// Null until the catalog loads; a removed environment is false. -const environmentRegisteredAtom = Atom.family((environmentId: EnvironmentId) => - Atom.make((get) => { - const catalog = get(environmentCatalog.catalogValueAtom); - return catalog.isReady ? catalog.entries.has(environmentId) : null; - }), -); export const threadEnvironment = createThreadEnvironmentAtoms( connectionAtomRuntime, threadFamilies.snapshotAtom, @@ -48,7 +41,6 @@ export const threadEnvironment = createThreadEnvironmentAtoms( shellStatusAtom: environmentShell.statusAtom, operateScopeAtom: environmentSession.operateScopeAtom, serverConfigAtom: serverEnvironment.configValueAtom, - registeredAtom: environmentRegisteredAtom, }, ); export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); diff --git a/apps/web/src/connection/threadTriageOutboxStore.browser.test.ts b/apps/web/src/connection/threadTriageOutboxStore.browser.test.ts index 6fae8f956aebe..0cb80297cedc9 100644 --- a/apps/web/src/connection/threadTriageOutboxStore.browser.test.ts +++ b/apps/web/src/connection/threadTriageOutboxStore.browser.test.ts @@ -7,7 +7,6 @@ import { import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Logger from "effect/Logger"; import { vi } from "vite-plus/test"; import { @@ -29,7 +28,7 @@ const replaceStored = (raw: string) => browserThreadTriageOutboxStorage.modify(( describe("browser thread triage storage", () => { it.effect("keeps every tab's change when tabs save at the same time", () => Effect.gen(function* () { - yield* replaceStored(JSON.stringify({ entries: [], operableEnvironmentIds: [] })); + yield* replaceStored(JSON.stringify({ entries: [] })); const tab = () => makeThreadTriageOutboxStore(browserThreadTriageOutboxStorage); yield* Effect.all([tab().put(settle("first")), tab().put(settle("second"))], { @@ -41,25 +40,6 @@ describe("browser thread triage storage", () => { }), ); - it.effect("discards an unreadable document on the caller's logger, then saves", () => - Effect.gen(function* () { - yield* replaceStored("{invalid"); - const warnings: unknown[] = []; - - yield* browserThreadTriageOutboxStore - .put(settle("thread")) - .pipe( - Effect.provideService( - Logger.CurrentLoggers, - new Set([Logger.make(({ message }) => warnings.push(message))]), - ), - ); - - expect(warnings).toHaveLength(1); - expect((yield* browserThreadTriageOutboxStore.load).entries).toEqual([settle("thread")]); - }), - ); - it.effect("clears retained environment state even when storage is unavailable", () => Effect.gen(function* () { yield* Effect.acquireRelease( diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index ee8fbcff5f8d9..2b87f89756c13 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -36,13 +36,6 @@ export const olderSettledThreads = createOlderSettledThreadAtoms( connectionAtomRuntime, environmentShell.stateAtom, ); -// Null until the catalog loads; a removed environment is false. -const environmentRegisteredAtom = Atom.family((environmentId: EnvironmentId) => - Atom.make((get) => { - const catalog = get(environmentCatalog.catalogValueAtom); - return catalog.isReady ? catalog.entries.has(environmentId) : null; - }), -); export const threadEnvironment = createThreadEnvironmentAtoms( connectionAtomRuntime, threadFamilies.snapshotAtom, @@ -50,10 +43,9 @@ export const threadEnvironment = createThreadEnvironmentAtoms( shellStatusAtom: environmentShell.statusAtom, operateScopeAtom: environmentSession.operateScopeAtom, serverConfigAtom: serverEnvironment.configValueAtom, - registeredAtom: environmentRegisteredAtom, }, ); -/** Settle, snooze and their undo can queue offline where the last session could operate. */ +/** Settle, snooze and their undo can queue offline unless a loaded session denies it. */ export function readThreadTriageScope(environmentId: EnvironmentId): boolean { return ( readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index db36891c05c52..217294de3762c 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -121,8 +121,6 @@ const browserTestProject = { "pdfjs-dist", "sql.js", "tiff", - "@effect/vitest", - "effect/Logger", ], }, test: { diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index f640f0e3532f3..71543dd13852e 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -173,12 +173,12 @@ when the thread settles** enabled in Settings > Projects > Actions. You can also that action so T3 can stop its terminal and child processes. Commands started through an agent's own shell tools are managed by the provider. -You can settle or snooze a thread while its environment is offline, if this device -could change threads there the last time it connected and the environment runs a -release with offline sync. The thread shows **Waiting to sync** on mobile or +You can settle or snooze a thread while its environment is offline, if the environment +runs a release with offline sync. The thread shows **Waiting to sync** on mobile or **Pending** on web and desktop, and the change is sent when the environment reconnects. If the thread changed there in the meantime, that environment's state -wins and your change is dropped. Undo a pending settle before snoozing the same +wins and your change is dropped, as it is when this connection cannot change threads there. +Undo a pending settle before snoozing the same thread while offline. To reclaim disk space from settled work, turn on **Run in the thread's worktree when the diff --git a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts index 6b98b178fe049..ceed09c3c25ed 100644 --- a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts +++ b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts @@ -23,12 +23,10 @@ export type PendingThreadTriage = typeof PendingThreadTriage.Type; const ThreadTriageOutboxDocument = Schema.Struct({ entries: Schema.Array(PendingThreadTriage), - /** Environments whose last loaded session could change threads. */ - operableEnvironmentIds: Schema.Array(EnvironmentId), }); export type ThreadTriageOutboxDocument = typeof ThreadTriageOutboxDocument.Type; -const EMPTY_DOCUMENT: ThreadTriageOutboxDocument = { entries: [], operableEnvironmentIds: [] }; +const EMPTY_DOCUMENT: ThreadTriageOutboxDocument = { entries: [] }; const StoredDocument = Schema.fromJsonString(ThreadTriageOutboxDocument); const decodeStored = Schema.decodeUnknownExit(StoredDocument); const encodeStored = Schema.encodeSync(StoredDocument); @@ -62,26 +60,18 @@ export const sameThreadTriage = (a: ThreadTriageKey, b: ThreadTriageKey) => */ export interface ThreadTriageOutboxStoreShape { readonly load: Effect.Effect; - /** - * Replaces any entry for the same thread, keeping its `attempted` mark. `stillCurrent` runs - * inside the store's lock, after any earlier removal, so a write for a removed environment is - * skipped. - */ + /** Replaces any entry for the same thread, keeping its `attempted` mark. */ readonly put: ( entry: PendingThreadTriage, - stillCurrent?: () => boolean, ) => Effect.Effect; /** Only removes the entry if it still has this command id. */ readonly remove: ( entry: ThreadTriageKey & Pick, ) => Effect.Effect; - /** - * Marks whatever is stored for the thread as sent and returns it, which may be another tab's - * replacement. Returns nothing when the thread has no stored change. - */ + /** Marks the entry as sent; false when it was cancelled or replaced since it was read. */ readonly markAttempted: ( - key: ThreadTriageKey, - ) => Effect.Effect; + entry: ThreadTriageKey & Pick, + ) => Effect.Effect; /** * Removes the entry only if it still has this command id and was never sent. Otherwise it * reports whether the entry was sent or is gone, and leaves storage as it is. @@ -89,11 +79,6 @@ export interface ThreadTriageOutboxStoreShape { readonly cancel: ( entry: ThreadTriageKey & Pick, ) => Effect.Effect<"cancelled" | "attempted" | "gone", ThreadTriageOutboxStorageError>; - readonly setOperable: ( - environmentId: EnvironmentId, - operable: boolean, - stillCurrent?: () => boolean, - ) => Effect.Effect; readonly clearEnvironment: ( environmentId: EnvironmentId, ) => Effect.Effect; @@ -170,21 +155,19 @@ export function makeThreadTriageOutboxStore(storage: { ); const updateEntries = ( transform: (entries: ReadonlyArray) => ReadonlyArray, - ) => update((document) => ({ ...document, entries: transform(document.entries) })); + ) => update((document) => ({ entries: transform(document.entries) })); return { load: lock.withPermits(1)(load), - put: (entry, stillCurrent) => + put: (entry) => Effect.suspend(() => { let blocked = false; return update((document) => { - if (stillCurrent?.() === false) return null; const replaced = document.entries.find((current) => sameThreadTriage(current, entry)); if (replaced?.action.type === "settle" && entry.action.type === "snooze") { blocked = true; return null; } return { - ...document, entries: [ ...document.entries.filter((current) => current !== replaced), // The replaced change may have reached the server, so the server must undo this one. @@ -203,21 +186,18 @@ export function makeThreadTriageOutboxStore(storage: { (current) => !sameThreadTriage(current, entry) || current.commandId !== entry.commandId, ), ), - markAttempted: (key) => + markAttempted: (entry) => Effect.suspend(() => { - let marked: PendingThreadTriage | undefined; + let marked = false; return update((document) => { - const stored = document.entries.find((current) => sameThreadTriage(current, key)); - if (stored === undefined) return null; - if (stored.attempted === true) { - marked = stored; - return null; - } - const next = { ...stored, attempted: true as const }; - marked = next; + const stored = document.entries.find((current) => sameThreadTriage(current, entry)); + if (stored?.commandId !== entry.commandId) return null; + marked = true; + if (stored.attempted === true) return null; return { - ...document, - entries: document.entries.map((current) => (current === stored ? next : current)), + entries: document.entries.map((current) => + current === stored ? { ...stored, attempted: true as const } : current, + ), }; }).pipe(Effect.map(() => marked)); }), @@ -232,28 +212,13 @@ export function makeThreadTriageOutboxStore(storage: { return null; } outcome = "cancelled"; - return { ...document, entries: document.entries.filter((current) => current !== stored) }; + return { entries: document.entries.filter((current) => current !== stored) }; }).pipe(Effect.map(() => outcome)); }), - setOperable: (environmentId, operable, stillCurrent) => - update((document) => - stillCurrent?.() === false - ? null - : { - ...document, - operableEnvironmentIds: [ - ...document.operableEnvironmentIds.filter((id) => id !== environmentId), - ...(operable ? [environmentId] : []), - ], - }, - ), clearEnvironment: (environmentId) => update( (document) => ({ entries: document.entries.filter((entry) => entry.environmentId !== environmentId), - operableEnvironmentIds: document.operableEnvironmentIds.filter( - (id) => id !== environmentId, - ), }), // The environment is gone even if storage failed, so memory must not replay it. Effect.sync(() => { diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 0b91b7073833f..a565c8333cf21 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -113,11 +113,9 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio reads?: Queue.Queue; }; readonly versionGuard?: boolean; - readonly operateScope?: boolean | null; /** The guard capability in the cached config, which may differ from the live server's. */ readonly cachedVersionGuard?: boolean; readonly wrapStore?: (store: ThreadTriageOutboxStoreShape) => ThreadTriageOutboxStoreShape; - readonly registered?: boolean | null; }) { const requests = yield* Queue.unbounded<{ command: OrchestrationV2Command; @@ -204,9 +202,6 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio const statusAtom = Atom.family((_environmentId: EnvironmentId) => Atom.make(options?.connected === false ? "cached" : "live"), ); - const operateScopeAtom = Atom.family((_environmentId: EnvironmentId) => - Atom.make(options?.operateScope === undefined ? true : options.operateScope), - ); const serverConfigAtom = Atom.family((_environmentId: EnvironmentId) => Atom.make({ environment: { @@ -214,14 +209,9 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio }, } as unknown as ServerConfig), ); - const registeredAtom = Atom.family((_environmentId: EnvironmentId) => - Atom.make(options?.registered === undefined ? true : options.registered), - ); const commands = createThreadEnvironmentAtoms(runtime, snapshotAtom, { shellStatusAtom: statusAtom, - operateScopeAtom, serverConfigAtom, - registeredAtom, }); const registry = AtomRegistry.make(); yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); @@ -239,8 +229,6 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio commands, snapshotAtom, statusAtom, - operateScopeAtom, - registeredAtom, visibleAtom, requests, reconciled, @@ -681,6 +669,15 @@ describe("settle and snooze while the environment is offline", () => { ? [] : (JSON.parse(storage.value) as { entries: Array> }).entries; + /** The request may have reached the server before the socket closed. */ + const lostReply = () => + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "socket closed", + cause: new Error("socket closed"), + }), + }); + // An older server cannot check the sequence, so the connected command goes out unguarded. it.effect.each([ { action: "settle", guarded: true }, @@ -756,50 +753,6 @@ describe("settle and snooze while the environment is offline", () => { }), ); - it.effect("loads every environment before refreshing an already-live shell", () => - Effect.gen(function* () { - const other = EnvironmentId.make("other"); - const secondStarted = yield* Deferred.make(); - const release = yield* Deferred.make(); - let reads = 0; - const storage = { - value: JSON.stringify({ - entries: [ - { - environmentId: other, - threadId: THREAD_ID, - commandId: "other-settle", - action: { type: "settle" }, - queuedAt: "2026-09-12T10:00:00.000Z", - snapshotSequence: 1, - }, - ], - operableEnvironmentIds: [other], - }), - }; - const h = yield* makeHarness({ - storage, - operateScope: null, - wrapStore: (store) => ({ - ...store, - load: Effect.suspend(() => - ++reads === 2 - ? Deferred.succeed(secondStarted, undefined).pipe( - Effect.andThen(Deferred.await(release)), - Effect.andThen(store.load), - ) - : store.load, - ), - }), - }); - yield* Deferred.await(secondStarted); - yield* Deferred.succeed(release, undefined); - const pending = h.commands.pendingSyncAtom(other); - yield* waitFor(h.registry, pending, (threads) => threads.has(THREAD_ID)); - expect(h.registry.get(h.commands.offlineTriagePermittedAtom(other))).toBe(true); - }), - ); - it.effect("keeps a pending settle when snooze would discard it offline", () => Effect.gen(function* () { const h = yield* makeHarness({ connected: false }); @@ -865,10 +818,10 @@ describe("settle and snooze while the environment is offline", () => { connected: false, wrapStore: (store) => ({ ...store, - put: (entry, stillCurrent) => + put: (entry) => Deferred.succeed(putStarted, undefined).pipe( Effect.andThen(Deferred.await(putReleased)), - Effect.andThen(store.put(entry, stillCurrent)), + Effect.andThen(store.put(entry)), ), }), }); @@ -887,38 +840,6 @@ describe("settle and snooze while the environment is offline", () => { }), ); - it.effect("replays a queued change when an Undo that failed lets go after reconnect", () => - Effect.gen(function* () { - const cancelStarted = yield* Deferred.make(); - const cancelReleased = yield* Deferred.make(); - const h = yield* makeHarness({ - connected: false, - wrapStore: (store) => ({ - ...store, - cancel: () => - Deferred.succeed(cancelStarted, undefined).pipe( - Effect.andThen(Deferred.await(cancelReleased)), - Effect.andThen( - Effect.fail(new ThreadTriageOutboxStorageError({ cause: "disk full" })), - ), - ), - }), - }); - h.registry.mount(h.commands.pendingSyncAtom(ENVIRONMENT_ID)); - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - const undo = h.commands.unsettle.run(h.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, reason: "user" }, - }); - yield* Deferred.await(cancelStarted); - yield* h.reconnect(); - yield* Deferred.succeed(cancelReleased, undefined); - expect((yield* Effect.promise(() => undo))._tag).toBe("Failure"); - const request = yield* Queue.take(h.requests); - expect(request.command.type).toBe("thread.settle"); - }), - ); - it.effect("restores a queued snooze after a restart and replays it once", () => Effect.gen(function* () { const storage = { value: null as string | null }; @@ -1130,22 +1051,6 @@ describe("settle and snooze while the environment is offline", () => { }), ); - it.effect("keeps the earlier queued change when its replacement cannot be saved", () => - Effect.gen(function* () { - const storage = { value: null as string | null, failWrites: false }; - const h = yield* makeHarness({ connected: false, storage }); - const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); - h.registry.mount(pending); - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - storage.failWrites = true; - const replacement = yield* Effect.promise(() => h.commands.snooze.run(h.registry, snooze)); - expect(replacement._tag).toBe("Failure"); - expect(h.registry.get(pending).has(THREAD_ID)).toBe(true); - expect(h.registry.get(h.visibleAtom)?.threads[0]?.settledOverride).toBe("settled"); - expect(stored(storage)).toMatchObject([{ action: { type: "settle" } }]); - }), - ); - it.effect("keeps queued changes to their own environment", () => Effect.gen(function* () { const h = yield* makeHarness({ connected: false }); @@ -1156,46 +1061,6 @@ describe("settle and snooze while the environment is offline", () => { }), ); - it.effect("remembers which environments may triage offline across a cold restart", () => - Effect.gen(function* () { - const writes = yield* Queue.unbounded(); - const storage = { value: null as string | null, writes }; - const online = yield* makeHarness({ storage, operateScope: true }); - online.registry.mount(online.commands.offlineTriagePermittedAtom(ENVIRONMENT_ID)); - yield* Queue.take(writes); - - const coldOffline = yield* makeHarness({ connected: false, storage, operateScope: null }); - const permitted = coldOffline.commands.offlineTriagePermittedAtom(ENVIRONMENT_ID); - coldOffline.registry.mount(permitted); - yield* waitFor(coldOffline.registry, permitted, (value) => value); - - // A session that loads without the scope wins and is remembered for the next restart. - coldOffline.registry.set(coldOffline.operateScopeAtom(ENVIRONMENT_ID), false); - expect(coldOffline.registry.get(permitted)).toBe(false); - yield* Queue.take(writes); - const restarted = yield* makeHarness({ connected: false, storage, operateScope: null }); - const restartedPermitted = restarted.commands.offlineTriagePermittedAtom(ENVIRONMENT_ID); - restarted.registry.mount(restartedPermitted); - yield* Effect.promise(() => - restarted.commands.settle.run(restarted.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID }, - }), - ); - expect(restarted.registry.get(restartedPermitted)).toBe(false); - }), - ); - - it.effect("does not allow offline triage for an environment never seen with the scope", () => - Effect.gen(function* () { - const h = yield* makeHarness({ connected: false, operateScope: null }); - const permitted = h.commands.offlineTriagePermittedAtom(ENVIRONMENT_ID); - h.registry.mount(permitted); - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - expect(h.registry.get(permitted)).toBe(false); - }), - ); - it.effect("keeps a queued settle when Undo cannot remove it from storage", () => Effect.gen(function* () { const storage = { value: null as string | null, failWrites: false }; @@ -1222,168 +1087,6 @@ describe("settle and snooze while the environment is offline", () => { }), ); - it.effect("does not restore changes of an environment removed while they load", () => - Effect.gen(function* () { - const writes = yield* Queue.unbounded(); - const storage = { value: null as string | null, writes }; - const before = yield* makeHarness({ connected: false, storage, operateScope: true }); - before.registry.mount(before.commands.offlineTriagePermittedAtom(ENVIRONMENT_ID)); - yield* Effect.promise(() => before.commands.settle.run(before.registry, settle)); - expect(stored(storage)).toHaveLength(1); - - // The removal lands after the read but before the loaded document is published. - // The session from before the removal is still loaded and still grants the scope. - const h = yield* makeHarness({ - connected: false, - storage, - operateScope: true, - registered: false, - wrapStore: (store) => ({ - ...store, - load: store.load.pipe(Effect.tap(() => store.clearEnvironment(ENVIRONMENT_ID))), - }), - }); - const laterThreadId = ThreadId.make("later-thread"); - h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), { - ...SNAPSHOT, - threads: [...SNAPSHOT.threads, { ...SNAPSHOT.threads[0]!, id: laterThreadId }], - }); - const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); - const permitted = h.commands.offlineTriagePermittedAtom(ENVIRONMENT_ID); - h.registry.mount(pending); - h.registry.mount(permitted); - // Queueing another thread waits for the load to publish its document. - yield* Effect.promise(() => - h.commands.settle.run(h.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: laterThreadId }, - }), - ); - expect([...h.registry.get(pending)]).toEqual([laterThreadId]); - expect(stored(storage)).toMatchObject([{ threadId: laterThreadId }]); - expect(h.registry.get(permitted)).toBe(false); - expect(JSON.parse(storage.value!).operableEnvironmentIds).toEqual([]); - }), - ); - - it.effect("does not let a retained session or an in-flight write restore a removed grant", () => - Effect.gen(function* () { - const writes = yield* Queue.unbounded(); - const storage = { value: null as string | null, writes }; - let removeBeforeWrite = true; - let unregister = () => {}; - const h = yield* makeHarness({ - connected: false, - storage, - operateScope: true, - // The removal lands while the grant's write waits for the store. - wrapStore: (store) => ({ - ...store, - setOperable: (environmentId, operable, stillCurrent) => - (removeBeforeWrite - ? Effect.sync(() => { - removeBeforeWrite = false; - unregister(); - }).pipe(Effect.andThen(store.clearEnvironment(environmentId))) - : Effect.void - ).pipe(Effect.andThen(store.setOperable(environmentId, operable, stillCurrent))), - }), - }); - unregister = () => h.registry.set(h.registeredAtom(ENVIRONMENT_ID), false); - const permitted = h.commands.offlineTriagePermittedAtom(ENVIRONMENT_ID); - h.registry.mount(permitted); - expect(JSON.parse(yield* Queue.take(writes)).operableEnvironmentIds).toEqual([]); - expect(h.registry.get(permitted)).toBe(false); - // The store runs writes in order, so this one follows the skipped grant. - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - expect(JSON.parse(yield* Queue.take(writes))).toMatchObject({ - entries: [{ threadId: THREAD_ID }], - operableEnvironmentIds: [], - }); - - // Adding the environment again lets its session be remembered. - h.registry.set(h.registeredAtom(ENVIRONMENT_ID), true); - expect(h.registry.get(permitted)).toBe(true); - expect(JSON.parse(yield* Queue.take(writes)).operableEnvironmentIds).toEqual([ - ENVIRONMENT_ID, - ]); - }), - ); - - it.effect("Undo after a queued settle was delivered un-settles on the server", () => - Effect.gen(function* () { - const h = yield* makeHarness({ connected: false }); - const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); - h.registry.mount(pending); - const queued = yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - expect(queued._tag === "Success" && queued.value.pendingSync).toBe(true); - yield* h.reconnect(); - const replay = yield* Queue.take(h.requests); - yield* Deferred.succeed(replay.reply, { sequence: 2 }); - yield* waitFor(h.registry, pending, (threads) => threads.size === 0); - - const undo = h.commands.unsettle.run(h.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, reason: "user" }, - }); - const request = yield* Queue.take(h.requests); - expect(request.command.type).toBe("thread.unsettle"); - yield* Deferred.succeed(request.reply, { sequence: 3 }); - const result = yield* Effect.promise(() => undo); - expect(result._tag === "Success" && ThreadUndo.cancelledPendingSync(result.value)).toBe( - false, - ); - }), - ); - - /** The request may have reached the server before the socket closed. */ - const lostReply = () => - new RpcClientError.RpcClientError({ - reason: new RpcClientError.RpcClientDefect({ - message: "socket closed", - cause: new Error("socket closed"), - }), - }); - - it.effect("Undo after a replay lost its reply un-settles on the server", () => - Effect.gen(function* () { - const h = yield* makeHarness({ connected: false }); - const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); - h.registry.mount(pending); - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - yield* h.reconnect(); - const replay = yield* Queue.take(h.requests); - // Waits behind the replay, so its request shows the replay has let go of the thread. - const behind = h.commands.unsnooze.run(h.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, reason: "user" }, - }); - yield* Deferred.fail(replay.reply, lostReply()); - const behindRequest = yield* Queue.take(h.requests); - yield* Deferred.succeed(behindRequest.reply, { sequence: 2 }); - yield* Effect.promise(() => behind); - expect(h.registry.get(pending).has(THREAD_ID)).toBe(true); - - const undo = h.commands.unsettle.run(h.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, reason: "user" }, - }); - const sent = yield* Effect.raceFirst( - Queue.take(h.requests), - Effect.promise(() => undo).pipe(Effect.as("cancelled locally" as const)), - ); - expect(sent).toMatchObject({ command: { type: "thread.unsettle" } }); - if (sent === "cancelled locally") return; - yield* Deferred.succeed(sent.reply, { sequence: 3 }); - const result = yield* Effect.promise(() => undo); - expect(result._tag === "Success" && ThreadUndo.cancelledPendingSync(result.value)).toBe( - false, - ); - expect(h.registry.get(pending).size).toBe(0); - expect(stored(h.storage)).toEqual([]); - }), - ); - it.effect("queues the snapshot the action was taken against, not a later one", () => Effect.gen(function* () { const loaded = yield* Deferred.make(); @@ -1407,32 +1110,6 @@ describe("settle and snooze while the environment is offline", () => { }), ); - it.effect("does not remember a session that answers after its environment was removed", () => - Effect.gen(function* () { - const writes = yield* Queue.unbounded(); - const storage = { value: null as string | null, writes }; - const h = yield* makeHarness({ connected: false, storage, operateScope: null }); - const permitted = h.commands.offlineTriagePermittedAtom(ENVIRONMENT_ID); - h.registry.mount(permitted); - // Removed while its first session request is still waiting. - h.registry.set(h.registeredAtom(ENVIRONMENT_ID), false); - yield* h.store.clearEnvironment(ENVIRONMENT_ID); - yield* Queue.take(writes); - h.registry.set(h.operateScopeAtom(ENVIRONMENT_ID), true); - expect(h.registry.get(permitted)).toBe(false); - // The store runs writes in order, so this one follows any grant write. - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - expect(JSON.parse(yield* Queue.take(writes)).operableEnvironmentIds).toEqual([]); - - // Registering it again lets its session be remembered. - h.registry.set(h.registeredAtom(ENVIRONMENT_ID), true); - expect(h.registry.get(permitted)).toBe(true); - expect(JSON.parse(yield* Queue.take(writes)).operableEnvironmentIds).toEqual([ - ENVIRONMENT_ID, - ]); - }), - ); - it.effect("drops a queued change once a newer action reaches the server behind its replay", () => Effect.gen(function* () { const h = yield* makeHarness({ connected: false }); @@ -1460,145 +1137,11 @@ describe("settle and snooze while the environment is offline", () => { }), ); - it.effect("keeps a change queued while a newer action was reaching the server", () => - Effect.gen(function* () { - const putStarted = yield* Deferred.make(); - const putReleased = yield* Deferred.make(); - const h = yield* makeHarness({ - connected: false, - wrapStore: (store) => ({ - ...store, - put: (entry, stillCurrent) => - Deferred.succeed(putStarted, undefined).pipe( - Effect.andThen(Deferred.await(putReleased)), - Effect.andThen(store.put(entry, stillCurrent)), - ), - }), - }); - const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); - h.registry.mount(pending); - const queued = h.commands.settle.run(h.registry, settle); - yield* Deferred.await(putStarted); - yield* h.reconnect("synchronizing"); - const online = h.commands.snooze.run(h.registry, snooze); - const request = yield* Queue.take(h.requests); - // The queued change lands while the newer action is still on its way. - yield* Deferred.succeed(putReleased, undefined); - expect((yield* Effect.promise(() => queued))._tag).toBe("Success"); - yield* Deferred.succeed(request.reply, { sequence: 2 }); - expect((yield* Effect.promise(() => online))._tag).toBe("Success"); - // The server decides on replay; the newer action must not erase it locally. - expect(h.registry.get(pending).has(THREAD_ID)).toBe(true); - expect(stored(h.storage)).toMatchObject([{ action: { type: "settle" } }]); - }), - ); - - // Two browser tabs share one storage, but each keeps its own copy of the queue in memory. - const otherTab = (storage: { value: string | null }) => - Effect.gen(function* () { - const tab = yield* makeHarness({ connected: false, storage }); - const pending = tab.commands.pendingSyncAtom(ENVIRONMENT_ID); - tab.registry.mount(pending); - yield* waitFor(tab.registry, pending, (threads) => threads.has(THREAD_ID)); - return { tab, pending }; - }); - const sentOrDropped = ( - tab: Effect.Success>, - pending: Atom.Atom>, - ) => - Effect.raceFirst( - Queue.take(tab.requests).pipe(Effect.map((request) => request.command)), - waitFor(tab.registry, pending, (threads) => threads.size === 0).pipe( - Effect.as("dropped" as const), - ), - ); - - it.effect("does not replay a change another tab cancelled", () => - Effect.gen(function* () { - const storage = { value: null as string | null }; - const first = yield* makeHarness({ connected: false, storage }); - yield* Effect.promise(() => first.commands.settle.run(first.registry, settle)); - const { tab: second, pending } = yield* otherTab(storage); - const undo = yield* Effect.promise(() => - first.commands.unsettle.run(first.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, reason: "user" }, - }), - ); - expect(undo._tag).toBe("Success"); - yield* second.reconnect(); - expect(yield* sentOrDropped(second, pending)).toBe("dropped"); - expect(second.registry.get(second.visibleAtom)).toBe(SNAPSHOT); - }), - ); - - it.effect("replays the change another tab queued in place of an older one", () => - Effect.gen(function* () { - const storage = { value: null as string | null }; - const first = yield* makeHarness({ connected: false, storage }); - yield* Effect.promise(() => first.commands.snooze.run(first.registry, snooze)); - const { tab: second, pending } = yield* otherTab(storage); - yield* Effect.promise(() => first.commands.settle.run(first.registry, settle)); - const [replacement] = stored(storage); - yield* second.reconnect(); - expect(yield* sentOrDropped(second, pending)).toMatchObject({ - type: "thread.settle", - commandId: replacement!.commandId, - }); - }), - ); - - it.effect("does not replay when it cannot read the stored queue", () => - Effect.gen(function* () { - const reads = yield* Queue.unbounded(); - const storage = { value: null as string | null, failReads: false, reads }; - const first = yield* makeHarness({ connected: false, storage }); - yield* Effect.promise(() => first.commands.settle.run(first.registry, settle)); - const { tab: second, pending } = yield* otherTab(storage); - yield* Queue.clear(reads); - storage.failReads = true; - yield* second.reconnect(); - yield* Queue.take(reads); - // A second reconnect only replays after the first attempt finished. - yield* second.disconnect; - yield* second.reconnect(); - yield* Queue.take(reads); - expect(yield* Queue.size(second.requests)).toBe(0); - expect(second.registry.get(pending).has(THREAD_ID)).toBe(true); - }), - ); - - const expireStoredSnooze = (storage: { value: string | null }) => { - storage.value = storage.value!.replace("2099-01-01T00:00:00.000Z", "2000-01-01T00:00:00.000Z"); - }; - - it.effect("Undo asks the server once another tab has sent the queued change", () => - Effect.gen(function* () { - const h = yield* makeHarness({ connected: false }); - h.registry.mount(h.commands.pendingSyncAtom(ENVIRONMENT_ID)); - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - const { tab, pending } = yield* otherTab(h.storage); - yield* tab.reconnect(); - const replay = yield* Queue.take(tab.requests); - yield* Deferred.succeed(replay.reply, { sequence: 2 }); - yield* waitFor(tab.registry, pending, (threads) => threads.size === 0); - - // This tab is still offline, so the server Undo it needs cannot reach the server. - const result = yield* Effect.promise(() => - h.commands.unsettle.run(h.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, reason: "user" }, - }), - ); - expect(result._tag).toBe("Failure"); - }), - ); - it.effect.each([ ["settle", "unsettle"], ["snooze", "unsnooze"], ] as const)( - "Undo after a restart or in another tab asks the server once a %s replay lost its reply", + "Undo after a restart asks the server once a %s replay lost its reply", ([action, undo]) => Effect.gen(function* () { const h = yield* makeHarness({ connected: false }); @@ -1607,7 +1150,10 @@ describe("settle and snooze while the environment is offline", () => { yield* h.reconnect(); const replay = yield* Queue.take(h.requests); yield* Deferred.fail(replay.reply, lostReply()); - const { tab } = yield* otherTab(h.storage); + const tab = yield* makeHarness({ connected: false, storage: h.storage }); + const pending = tab.commands.pendingSyncAtom(ENVIRONMENT_ID); + tab.registry.mount(pending); + yield* waitFor(tab.registry, pending, (threads) => threads.has(THREAD_ID)); // Still offline here, so the server Undo it needs cannot reach the server. const result = yield* Effect.promise(() => @@ -1648,70 +1194,4 @@ describe("settle and snooze while the environment is offline", () => { expect(result._tag).toBe("Failure"); }), ); - - it.effect("does not send a queued change it cannot record as sent", () => - Effect.gen(function* () { - const markFailed = yield* Deferred.make(); - let failMark = true; - const h = yield* makeHarness({ - connected: false, - wrapStore: (store) => ({ - ...store, - markAttempted: (key) => - failMark - ? Deferred.succeed(markFailed, undefined).pipe( - Effect.andThen( - Effect.fail(new ThreadTriageOutboxStorageError({ cause: "disk full" })), - ), - ) - : store.markAttempted(key), - }), - }); - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - yield* h.reconnect(); - const first = yield* Effect.raceFirst( - Deferred.await(markFailed).pipe(Effect.as("not sent" as const)), - Queue.take(h.requests).pipe(Effect.as("sent" as const)), - ); - expect(first).toBe("not sent"); - failMark = false; - // A second reconnect only replays after the first attempt finished. - yield* h.disconnect; - yield* h.reconnect(); - const request = yield* Queue.take(h.requests); - expect(request.command.type).toBe("thread.settle"); - expect(yield* Queue.size(h.requests)).toBe(0); - }), - ); - - it.effect("judges expiry by the replacement another tab stored, not the cached change", () => - Effect.gen(function* () { - const storage = { value: null as string | null }; - const first = yield* makeHarness({ connected: false, storage }); - yield* Effect.promise(() => first.commands.snooze.run(first.registry, snooze)); - expireStoredSnooze(storage); - const { tab: second, pending } = yield* otherTab(storage); - yield* Effect.promise(() => first.commands.settle.run(first.registry, settle)); - const [replacement] = stored(storage); - yield* second.reconnect(); - expect(yield* sentOrDropped(second, pending)).toMatchObject({ - type: "thread.settle", - commandId: replacement!.commandId, - }); - }), - ); - - it.effect("drops a replacement another tab stored once its snooze expired", () => - Effect.gen(function* () { - const storage = { value: null as string | null }; - const first = yield* makeHarness({ connected: false, storage }); - yield* Effect.promise(() => first.commands.snooze.run(first.registry, snooze)); - const { tab: second, pending } = yield* otherTab(storage); - yield* Effect.promise(() => first.commands.snooze.run(first.registry, snooze)); - expireStoredSnooze(storage); - yield* second.reconnect(); - expect(yield* sentOrDropped(second, pending)).toBe("dropped"); - expect(stored(storage)).toEqual([]); - }), - ); }); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 65dcc0ad5ec98..cc8c54c134acc 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -122,8 +122,6 @@ export function createThreadEnvironmentAtoms( readonly shellStatusAtom?: (environmentId: EnvironmentId) => Atom.Atom; /** Whether the loaded session can change threads; null until it loads. */ readonly operateScopeAtom?: (environmentId: EnvironmentId) => Atom.Atom; - /** Whether the environment is registered; null until the catalog loads. */ - readonly registeredAtom?: (environmentId: EnvironmentId) => Atom.Atom; /** Cached config; offline changes queue only for servers that can check them. */ readonly serverConfigAtom?: (environmentId: EnvironmentId) => Atom.Atom; } = {}, @@ -433,7 +431,10 @@ export function createThreadEnvironmentAtoms( const triage = createThreadTriageOutbox({ runtime, sourceSnapshotAtom: snapshotAtom, - ...options, + ...(options.shellStatusAtom === undefined ? {} : { shellStatusAtom: options.shellStatusAtom }), + ...(options.serverConfigAtom === undefined + ? {} + : { serverConfigAtom: options.serverConfigAtom }), applySettle: (thread, queuedAt) => applySettle(thread, queuedAt, false), applySnooze: (thread, snoozedUntil, queuedAt) => applySnooze(thread, snoozedUntil, queuedAt, false), @@ -456,7 +457,13 @@ export function createThreadEnvironmentAtoms( ...commands, snapshotAtom: optimistic.snapshotAtom, pendingSyncAtom: triage.pendingSyncAtom, - offlineTriagePermittedAtom: triage.offlineTriagePermittedAtom, + /** Until a session loads, offline settle and snooze stay available; replay is checked again. */ + offlineTriagePermittedAtom: Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const operateScopeAtom = options.operateScopeAtom?.(environmentId); + return operateScopeAtom === undefined || get(operateScopeAtom) !== false; + }), + ), settle: triage.queueWhenOffline(settle, () => ({ type: "settle" as const })), unsettle: triage.cancelQueued( optimistic.wrap(commands.unsettle, (thread, input, now) => ({ diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index b8224bdc34c68..46cc0fbb11c53 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -39,24 +39,15 @@ type Command = AtomCommand< TriageResult, E >; +type StoredEntry = ThreadTriageKey & Pick; type StoreOperation = | { readonly _tag: "load" | "refresh" } - | { - readonly _tag: "put"; - readonly entry: Omit; - readonly stillCurrent: () => boolean; - } - | { readonly _tag: "remove"; readonly entry: PendingThreadTriage } - | { readonly _tag: "markAttempted"; readonly key: ThreadTriageKey } - | { - readonly _tag: "setOperable"; - readonly environmentId: EnvironmentId; - readonly operable: boolean; - readonly stillCurrent: () => boolean; - }; + | { readonly _tag: "put"; readonly entry: Omit } + | { readonly _tag: "remove"; readonly entry: StoredEntry } + | { readonly _tag: "markAttempted"; readonly entry: PendingThreadTriage }; -const EMPTY_DOCUMENT: ThreadTriageOutboxDocument = { entries: [], operableEnvironmentIds: [] }; +const EMPTY_DOCUMENT: ThreadTriageOutboxDocument = { entries: [] }; const isUnavailable = Schema.is(EnvironmentRpcUnavailableError); const isGuardUnsupported = Schema.is(ThreadVersionGuardUnsupportedError); @@ -88,6 +79,16 @@ function isTransient(cause: Cause.Cause) { ); } +const sameEntry = (a: StoredEntry, b: StoredEntry) => + sameThreadTriage(a, b) && a.commandId === b.commandId; + +/** Keeps the array when nothing changes, so a no-op does not wake the replay again. */ +function withoutEntry(entries: ReadonlyArray, entry: StoredEntry) { + return entries.some((current) => sameEntry(current, entry)) + ? entries.filter((current) => !sameEntry(current, entry)) + : entries; +} + export function createThreadTriageOutbox(input: { readonly runtime: Atom.AtomRuntime; readonly sourceSnapshotAtom: ( @@ -95,13 +96,6 @@ export function createThreadTriageOutbox(input: { ) => Atom.Atom; /** Replay waits for a synchronized shell; without one, nothing replays. */ readonly shellStatusAtom?: (environmentId: EnvironmentId) => Atom.Atom; - /** Whether the loaded session can change threads; null until it loads. */ - readonly operateScopeAtom?: (environmentId: EnvironmentId) => Atom.Atom; - /** - * Whether the environment is registered; null until the catalog loads. Only a registered - * environment's session may be remembered, so a removed one's late session grants nothing. - */ - readonly registeredAtom?: (environmentId: EnvironmentId) => Atom.Atom; /** Cached config; only servers that check the snapshot sequence get queued changes. */ readonly serverConfigAtom?: (environmentId: EnvironmentId) => Atom.Atom; readonly applySettle: ( @@ -126,6 +120,12 @@ export function createThreadTriageOutbox(input: { registry: AtomRegistry.AtomRegistry, ) { const outbox = yield* ThreadTriageOutboxStore; + const logFailure = + (message: string) => + (effect: Effect.Effect) => + effect.pipe( + Effect.tapError((error) => Effect.logWarning(message, { cause: String(error.cause) })), + ); switch (operation._tag) { case "refresh": return yield* outbox.load; @@ -144,7 +144,7 @@ export function createThreadTriageOutbox(input: { ...operation.entry, commandId: CommandId.make(yield* random.uuidV4()), }; - yield* outbox.put(entry, operation.stillCurrent).pipe( + yield* outbox.put(entry).pipe( Effect.tapError((error) => error._tag === "ThreadTriagePendingSettleError" ? Effect.void @@ -153,38 +153,19 @@ export function createThreadTriageOutbox(input: { }), ), ); - return { ...EMPTY_DOCUMENT, entries: [entry] }; + return { entries: [entry] }; } case "remove": - yield* outbox.remove(operation.entry).pipe( - Effect.tapError((error) => - Effect.logWarning("Could not remove an offline thread change.", { - cause: String(error.cause), - }), - ), - ); + yield* outbox + .remove(operation.entry) + .pipe(logFailure("Could not remove an offline thread change.")); return EMPTY_DOCUMENT; case "markAttempted": { - const marked = yield* outbox.markAttempted(operation.key).pipe( - Effect.tapError((error) => - Effect.logWarning("Could not record an offline thread change as sent.", { - cause: String(error.cause), - }), - ), - ); - return { ...EMPTY_DOCUMENT, entries: marked === undefined ? [] : [marked] }; + const marked = yield* outbox + .markAttempted(operation.entry) + .pipe(logFailure("Could not record an offline thread change as sent.")); + return { entries: marked ? [operation.entry] : [] }; } - case "setOperable": - yield* outbox - .setOperable(operation.environmentId, operation.operable, operation.stillCurrent) - .pipe( - Effect.catch((error) => - Effect.logWarning("Could not remember thread permissions.", { - cause: String(error.cause), - }), - ), - ); - return EMPTY_DOCUMENT; } }), }); @@ -203,24 +184,15 @@ export function createThreadTriageOutbox(input: { }), }); - /** Bumped when an environment is removed so in-flight work does not re-add its entries. */ - const generations = new Map(); const documentAtom = Atom.make(null).pipe(Atom.keepAlive); let loading: Promise | undefined; const ensureLoaded = (registry: AtomRegistry.AtomRegistry) => { const loaded = registry.get(documentAtom); if (loaded !== null) return Promise.resolve(loaded); - if (loading !== undefined) return loading; - const loadStarted = new Map(generations); - loading = store.run(registry, { _tag: "load" }).then((result) => { - const loadedDocument = AsyncResult.isSuccess(result) ? result.value : EMPTY_DOCUMENT; - // An environment removed while the read was in flight must not come back from it. - const removed = (environmentId: EnvironmentId) => - (generations.get(environmentId) ?? 0) !== (loadStarted.get(environmentId) ?? 0); - const current = registry.get(documentAtom) ?? { - entries: loadedDocument.entries.filter((entry) => !removed(entry.environmentId)), - operableEnvironmentIds: loadedDocument.operableEnvironmentIds.filter((id) => !removed(id)), - }; + loading ??= store.run(registry, { _tag: "load" }).then((result) => { + const current = + registry.get(documentAtom) ?? + (AsyncResult.isSuccess(result) ? result.value : EMPTY_DOCUMENT); registry.set(documentAtom, current); return current; }); @@ -237,18 +209,6 @@ export function createThreadTriageOutbox(input: { Atom.make((get) => get(entriesAtom).filter((entry) => entry.environmentId === environmentId)), ); - /** One owner per thread at a time: a replay, an Undo, or a replacing action. */ - const claimed = new Set(); - const clearLocal = (registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId) => { - generations.set(environmentId, (generations.get(environmentId) ?? 0) + 1); - const document = registry.get(documentAtom); - if (document === null) return; - registry.set(documentAtom, { - entries: document.entries.filter((entry) => entry.environmentId !== environmentId), - operableEnvironmentIds: document.operableEnvironmentIds.filter((id) => id !== environmentId), - }); - }; - const keyOf = (entry: ThreadTriageKey) => JSON.stringify([entry.environmentId, entry.threadId]); const pendingFor = (registry: AtomRegistry.AtomRegistry, key: ThreadTriageKey) => registry.get(documentAtom)?.entries.find((entry) => sameThreadTriage(entry, key)); const setLocal = ( @@ -256,7 +216,14 @@ export function createThreadTriageOutbox(input: { transform: (entries: ReadonlyArray) => ReadonlyArray, ) => { const document = registry.get(documentAtom) ?? EMPTY_DOCUMENT; - registry.set(documentAtom, { ...document, entries: transform(document.entries) }); + const entries = transform(document.entries); + if (entries !== document.entries) registry.set(documentAtom, { entries }); + }; + const clearLocal = (registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId) => { + if (registry.get(documentAtom) === null) return; + setLocal(registry, (entries) => + entries.filter((entry) => entry.environmentId !== environmentId), + ); }; /** Ends with the registry, so replay stops touching it once the app or a test tears it down. */ const registryLifetimeAtom = Atom.make((get) => { @@ -271,51 +238,32 @@ export function createThreadTriageOutbox(input: { * Hides the entry even if storage keeps it: a restart then replays it with the same command * id, which the server deduplicates or rejects. Cancelling must check the result instead. */ - const forget = async (registry: AtomRegistry.AtomRegistry, entry: PendingThreadTriage) => { + const forget = async (registry: AtomRegistry.AtomRegistry, entry: StoredEntry) => { const lifetime = registry.get(registryLifetimeAtom); const removed = await store.run(registry, { _tag: "remove", entry }); - if (!lifetime.ended) { - setLocal(registry, (entries) => entries.filter((current) => current !== entry)); - } + if (!lifetime.ended) setLocal(registry, (entries) => withoutEntry(entries, entry)); return removed; }; - /** Reconnect skips a claimed thread, so whoever releases one replays what is still queued. */ - const release = ( - registry: AtomRegistry.AtomRegistry, - key: ThreadTriageKey, - lifetime: { readonly ended: boolean }, - ) => { - claimed.delete(keyOf(key)); - if ( - !lifetime.ended && - input.shellStatusAtom !== undefined && - registry.get(input.shellStatusAtom(key.environmentId)) === "live" && - pendingFor(registry, key) !== undefined - ) { - void replay(registry, key.environmentId); - } - }; - - const replayingEnvironments = new Set(); + const replaying = new Set(); const replayAgain = new Set(); + const isLive = (registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId) => + input.shellStatusAtom !== undefined && + registry.get(input.shellStatusAtom(environmentId)) === "live"; const replay = async (registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId) => { - if (replayingEnvironments.has(environmentId)) { + if (replaying.has(environmentId)) { replayAgain.add(environmentId); return; } const lifetime = registry.get(registryLifetimeAtom); - replayingEnvironments.add(environmentId); + replaying.add(environmentId); try { await replayEntries(registry, environmentId, lifetime); } finally { - replayingEnvironments.delete(environmentId); + replaying.delete(environmentId); } - if (lifetime.ended) return; - if (replayAgain.delete(environmentId) && input.shellStatusAtom !== undefined) { - if (registry.get(input.shellStatusAtom(environmentId)) === "live") { - await replay(registry, environmentId); - } + if (!lifetime.ended && replayAgain.delete(environmentId) && isLive(registry, environmentId)) { + await replay(registry, environmentId); } }; const replayEntries = async ( @@ -323,110 +271,58 @@ export function createThreadTriageOutbox(input: { environmentId: EnvironmentId, lifetime: { readonly ended: boolean }, ) => { - for (const { threadId } of registry.get(environmentEntriesAtom(environmentId))) { - // Earlier replies may have let Undo, a replacement, or a removal change this entry. - const entry = pendingFor(registry, { environmentId, threadId }); - if (entry === undefined) continue; - const key = keyOf(entry); - if (claimed.has(key)) continue; - claimed.add(key); - try { - // Marked before sending, so an Undo after a lost reply, a restart, or in another tab asks - // the server. Storage also shows whether another tab cancelled or replaced the change. - const marked = await store.run(registry, { _tag: "markAttempted", key: entry }); - if (lifetime.ended || !AsyncResult.isSuccess(marked)) return; - if (pendingFor(registry, entry) !== entry) continue; - const stored = marked.value.entries[0]; - if (stored === undefined) { - setLocal(registry, (entries) => entries.filter((existing) => existing !== entry)); - continue; - } - const current = stored.commandId === entry.commandId ? entry : stored; - if (current !== entry) { - setLocal(registry, (entries) => - entries.map((existing) => (existing === entry ? current : existing)), - ); - } - // Expiry belongs to the change actually sent, which may be another tab's replacement. - if ( - current.action.type === "snooze" && - !(Date.parse(current.action.snoozedUntil) > DateTime.toEpochMillis(DateTime.nowUnsafe())) - ) { - await forget(registry, current); - if (lifetime.ended) return; - continue; - } - const result = await input.replay(registry, current); + await ensureLoaded(registry); + // Storage decides what to send: another tab may have queued, replaced, or cancelled a change. + const loaded = await store.run(registry, { _tag: "refresh" }); + if (lifetime.ended || !AsyncResult.isSuccess(loaded)) return; + const stored = loaded.value.entries.filter((entry) => entry.environmentId === environmentId); + setLocal(registry, (entries) => { + const kept = entries.filter( + (entry) => + entry.environmentId !== environmentId || + stored.some((candidate) => sameEntry(candidate, entry)), + ); + const added = stored.filter((entry) => !entries.some((current) => sameEntry(current, entry))); + return kept.length === entries.length && added.length === 0 ? entries : [...kept, ...added]; + }); + for (const entry of stored) { + if ( + entry.action.type === "snooze" && + !(Date.parse(entry.action.snoozedUntil) > DateTime.toEpochMillis(DateTime.nowUnsafe())) + ) { + await forget(registry, entry); if (lifetime.ended) return; - if (AsyncResult.isSuccess(result) || !isTransient(result.cause)) { - await forget(registry, current); - if (lifetime.ended) return; - } else { - return; - } - } finally { - claimed.delete(key); + continue; } + // Marked before sending, so an Undo after a lost reply or a restart asks the server. + const marked = await store.run(registry, { _tag: "markAttempted", entry }); + if (lifetime.ended || !AsyncResult.isSuccess(marked)) return; + // Cancelled or replaced after storage was read. + if (marked.value.entries.length === 0) { + setLocal(registry, (entries) => withoutEntry(entries, entry)); + continue; + } + const result = await input.replay(registry, entry); + if (lifetime.ended) return; + if (!AsyncResult.isSuccess(result) && isTransient(result.cause)) return; + await forget(registry, entry); + if (lifetime.ended) return; } }; - const reconnectAtom = Atom.family((environmentId: EnvironmentId) => + const replayAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => { + get(environmentEntriesAtom(environmentId)); if ( - input.shellStatusAtom === undefined || - get(input.shellStatusAtom(environmentId)) !== "live" + input.shellStatusAtom !== undefined && + get(input.shellStatusAtom(environmentId)) === "live" ) - return; - const registry = get.registry; - const lifetime = registry.get(registryLifetimeAtom); - const generation = generations.get(environmentId) ?? 0; - void ensureLoaded(registry).then(async () => { - if (lifetime.ended || (generations.get(environmentId) ?? 0) !== generation) return; - const result = await store.run(registry, { _tag: "refresh" }); - if ( - lifetime.ended || - (generations.get(environmentId) ?? 0) !== generation || - !AsyncResult.isSuccess(result) - ) - return; - // A reconnect also discovers actions queued by a tab that has since closed. - setLocal(registry, (entries) => { - const next = [ - ...entries.filter( - (entry) => entry.environmentId !== environmentId || claimed.has(keyOf(entry)), - ), - ...result.value.entries - .filter( - (entry) => entry.environmentId === environmentId && !claimed.has(keyOf(entry)), - ) - .map( - (entry) => - entries.find( - (current) => - sameThreadTriage(current, entry) && current.commandId === entry.commandId, - ) ?? entry, - ), - ]; - return next.length === entries.length && - next.every((entry, index) => entry === entries[index]) - ? entries - : next; - }); - }); - }), - ); - - const replayAtom = Atom.family((environmentId: EnvironmentId) => - Atom.make((get) => { - const status = input.shellStatusAtom ? get(input.shellStatusAtom(environmentId)) : "empty"; - const entries = get(environmentEntriesAtom(environmentId)); - if (status === "live" && entries.length > 0) void replay(get.registry, environmentId); + void replay(get.registry, environmentId); }), ); const snapshotAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => { - get.mount(reconnectAtom(environmentId)); get.mount(replayAtom(environmentId)); // Loads the cached config before an offline action needs to read it. if (input.serverConfigAtom) get.mount(input.serverConfigAtom(environmentId)); @@ -464,102 +360,83 @@ export function createThreadTriageOutbox(input: { run: async (registry, target) => { const { environmentId } = target; const key = { environmentId, threadId: target.input.threadId }; - const claimKey = keyOf(key); - const lifetime = registry.get(registryLifetimeAtom); - // A replay already holding the thread finishes first; the scheduler runs this after it. - const owns = !claimed.has(claimKey); - if (owns) claimed.add(claimKey); - const generation = generations.get(environmentId) ?? 0; - const current = () => (generations.get(environmentId) ?? 0) === generation; // The server compares against what the user saw when acting, not what arrives later. const snapshot = registry.get(input.sourceSnapshotAtom(environmentId)); const thread = snapshot?.threads.find((candidate) => candidate.id === key.threadId); - const superseded = pendingFor(registry, key); const guarded = (input.serverConfigAtom ? registry.get(input.serverConfigAtom(environmentId)) : null) ?.environment.capabilities.threadTriageVersionGuard === true; - try { - // Started before any await so the optimistic change shows in the same frame. - const result = await command.run( - registry, - snapshot !== null && - guarded && - input.shellStatusAtom !== undefined && - registry.get(input.shellStatusAtom(environmentId)) !== "live" - ? { - ...target, - input: { ...target.input, snapshotSequence: snapshot.snapshotSequence }, - } - : target, + // Started before any await so the optimistic change shows in the same frame. + const result = await command.run( + registry, + snapshot !== null && + guarded && + input.shellStatusAtom !== undefined && + registry.get(input.shellStatusAtom(environmentId)) !== "live" + ? { + ...target, + input: { ...target.input, snapshotSequence: snapshot.snapshotSequence }, + } + : target, + ); + if (AsyncResult.isSuccess(result)) { + // The server has a newer action, so its guard would reject a queued one anyway. + const previous = pendingFor(registry, key); + if (previous !== undefined) await forget(registry, previous); + return result; + } + const offline = offlineError(result.cause); + if (offline === undefined) return result; + if (!guarded) { + return AsyncResult.failure( + Cause.fail( + new ThreadVersionGuardUnsupportedError({ + message: `${offline.message} Update its server to settle or snooze while it is offline.`, + }), + ), ); - if (AsyncResult.isSuccess(result)) { - // The server now has a newer action, which the change it replaced would only fight. - // A change queued after this one started stays, and one a replay holds is left to it. - const previous = pendingFor(registry, key); - if ( - previous !== undefined && - previous === superseded && - (owns || !claimed.has(claimKey)) - ) { - await forget(registry, previous); - } - return result; - } - const offline = offlineError(result.cause); - if (offline === undefined) return result; - if (!guarded) { - return AsyncResult.failure( - Cause.fail( - new ThreadVersionGuardUnsupportedError({ - message: `${offline.message} Update its server to settle or snooze while it is offline.`, - }), - ), - ); - } - await ensureLoaded(registry); - if (snapshot == null || thread === undefined || !current()) return result; - const queuedAction = action(target.input); - const now = DateTime.nowUnsafe(); - if ( - (queuedAction.type === "settle" - ? input.applySettle(thread, now) - : input.applySnooze(thread, queuedAction.snoozedUntil, now)) === thread + } + await ensureLoaded(registry); + if (snapshot == null || thread === undefined) return result; + const queuedAction = action(target.input); + const now = DateTime.nowUnsafe(); + if ( + (queuedAction.type === "settle" + ? input.applySettle(thread, now) + : input.applySnooze(thread, queuedAction.snoozedUntil, now)) === thread + ) + return result; + // The put replaces any earlier entry for the thread, so a failed save keeps it. + const queued = await store.run(registry, { + _tag: "put", + entry: { + ...key, + action: queuedAction, + queuedAt: now, + snapshotSequence: snapshot.snapshotSequence, + }, + }); + if (!AsyncResult.isSuccess(queued)) { + return queued.cause.reasons.some( + (reason) => Cause.isFailReason(reason) && isPendingSettle(reason.error), ) - return result; - // The put replaces any earlier entry for the thread, so a failed save keeps it. - const queued = await store.run(registry, { - _tag: "put", - entry: { - ...key, - action: action(target.input), - queuedAt: DateTime.nowUnsafe(), - snapshotSequence: snapshot.snapshotSequence, - }, - stillCurrent: current, - }); - if (!AsyncResult.isSuccess(queued)) { - return queued.cause.reasons.some( - (reason) => Cause.isFailReason(reason) && isPendingSettle(reason.error), - ) - ? AsyncResult.failure(Cause.fail(new ThreadTriagePendingSettleError())) - : result; - } - // A removal before the write skipped it; one after it already deleted the entry. - if (!current()) return result; - const entry = queued.value.entries[0]!; - setLocal(registry, (entries) => [ - ...entries.filter((existing) => !sameThreadTriage(existing, entry)), - entry, - ]); - return AsyncResult.success({ sequence: snapshot.snapshotSequence, pendingSync: true }); - } finally { - if (owns) release(registry, key, lifetime); + ? AsyncResult.failure(Cause.fail(new ThreadTriagePendingSettleError())) + : result; } + const entry = queued.value.entries[0]!; + setLocal(registry, (entries) => [ + ...entries.filter((existing) => !sameThreadTriage(existing, entry)), + entry, + ]); + return AsyncResult.success({ sequence: snapshot.snapshotSequence, pendingSync: true }); }, }; } - /** Undo before sync cancels the queued action instead of asking the server. */ + /** + * Undo of a change that was never sent cancels it locally. Once it was sent, even if the reply + * was lost, only the server can undo it. + */ function cancelQueued( command: AtomCommand<{ readonly environmentId: EnvironmentId; readonly input: I }, A, CE>, cancels: PendingThreadTriage["action"]["type"], @@ -576,89 +453,26 @@ export function createThreadTriageOutbox(input: { environmentId: target.environmentId, threadId: target.input.threadId, }); - const claimKey = pending === undefined ? undefined : keyOf(pending); - if ( - pending === undefined || - claimKey === undefined || - pending.action.type !== cancels || - claimed.has(claimKey) - ) { - // A claimed entry may already be on its way; the server undoes it after it lands. - const result = await command.run(registry, target); - if ( - AsyncResult.isSuccess(result) && - pending !== undefined && - pending.action.type === cancels && - pendingFor(registry, pending) === pending - ) { - await forget(registry, pending); - } - return result; + if (pending === undefined || pending.action.type !== cancels) { + return command.run(registry, target); } - const lifetime = registry.get(registryLifetimeAtom); - claimed.add(claimKey); - try { - // Storage decides, because another tab or an earlier run may have sent it. - const outcome = await cancelStored.run(registry, pending); - if (!AsyncResult.isSuccess(outcome)) return AsyncResult.failure(outcome.cause); - if (outcome.value !== "attempted") { - setLocal(registry, (entries) => entries.filter((current) => current !== pending)); - } - if (outcome.value === "cancelled") return AsyncResult.success(cancelledValue); - const result = await command.run(registry, target); - // The undo is newer on the server, which then rejects the queued change anyway. - if (AsyncResult.isSuccess(result) && pendingFor(registry, pending) === pending) { - await forget(registry, pending); - } - return result; - } finally { - release(registry, pending, lifetime); + // Storage decides, because a replay, a restart, or another tab may have sent it. + const outcome = await cancelStored.run(registry, pending); + if (!AsyncResult.isSuccess(outcome)) return AsyncResult.failure(outcome.cause); + if (outcome.value !== "attempted") { + setLocal(registry, (entries) => withoutEntry(entries, pending)); } + if (outcome.value === "cancelled") return AsyncResult.success(cancelledValue); + const result = await command.run(registry, target); + if (AsyncResult.isSuccess(result)) await forget(registry, pending); + return result; }, }; } - /** - * Settle and snooze stay available offline only where the last loaded session could - * change threads. A loaded session always decides, and replay is checked again by the server. - */ - const offlineTriagePermittedAtom = Atom.family((environmentId: EnvironmentId) => - Atom.make((get) => { - const registeredAtom = input.registeredAtom?.(environmentId); - const registered = registeredAtom === undefined ? true : get(registeredAtom); - if (registered === false) return false; - const known = input.operateScopeAtom ? get(input.operateScopeAtom(environmentId)) : null; - const document = get(loadedDocumentAtom); - const remembered = document?.operableEnvironmentIds.includes(environmentId) ?? false; - if (known === null) return remembered; - const generation = generations.get(environmentId) ?? 0; - if (registered === true && document !== null && known !== remembered) { - const registry = get.registry; - registry.set(documentAtom, { - ...document, - operableEnvironmentIds: [ - ...document.operableEnvironmentIds.filter((id) => id !== environmentId), - ...(known ? [environmentId] : []), - ], - }); - void store.run(registry, { - _tag: "setOperable", - environmentId, - operable: known, - // Checked inside the store's lock, after any removal that started first. - stillCurrent: () => - (generations.get(environmentId) ?? 0) === generation && - (registeredAtom === undefined || registry.get(registeredAtom) === true), - }); - } - return known; - }), - ); - return { snapshotAtom, pendingSyncAtom, - offlineTriagePermittedAtom, queueWhenOffline, cancelQueued, }; From dbe264f6599ac133910ed432021d3fb140dbe138 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:06:18 +0000 Subject: [PATCH 33/44] fix(client-runtime): stop replay storms and late writes after removal A change the server already answered stays skipped for the session even if storage could not drop it, so a failed delete no longer makes every replay pass send it again. A queued write that lands after its environment was removed is undone. Co-Authored-By: Claude Opus 5.5 --- .../src/state/threadCommands.test.ts | 94 +++++++++++++++++++ .../src/state/threadTriageOutbox.ts | 16 +++- 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index a565c8333cf21..b84ce7e87939e 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -1038,6 +1038,100 @@ describe("settle and snooze while the environment is offline", () => { }), ); + it.effect("does not keep a change queued while its environment was being removed", () => + Effect.gen(function* () { + const putStarted = yield* Deferred.make(); + const putReleased = yield* Deferred.make(); + const h = yield* makeHarness({ + connected: false, + wrapStore: (store) => ({ + ...store, + put: (entry) => + Deferred.succeed(putStarted, undefined).pipe( + Effect.andThen(Deferred.await(putReleased)), + Effect.andThen(store.put(entry)), + ), + }), + }); + const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); + h.registry.mount(pending); + const queued = h.commands.settle.run(h.registry, settle); + yield* Deferred.await(putStarted); + yield* h.store.clearEnvironment(ENVIRONMENT_ID); + yield* Deferred.succeed(putReleased, undefined); + expect((yield* Effect.promise(() => queued))._tag).toBe("Failure"); + expect(h.registry.get(pending).size).toBe(0); + expect(stored(h.storage)).toEqual([]); + }), + ); + + it.effect("sends a replayed change once even when storage cannot drop it", () => + Effect.gen(function* () { + const loads = yield* Queue.unbounded(); + const h = yield* makeHarness({ + connected: false, + wrapStore: (store) => ({ + ...store, + load: store.load.pipe(Effect.tap(() => Queue.offer(loads, undefined))), + remove: () => Effect.fail(new ThreadTriageOutboxStorageError({ cause: "disk full" })), + }), + }); + const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); + h.registry.mount(pending); + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + yield* h.reconnect(); + const replay = yield* Queue.take(h.requests); + yield* Queue.clear(loads); + yield* Deferred.succeed(replay.reply, { sequence: 2 }); + // Forgetting it locally starts another pass, which reads the entry storage kept. + yield* Queue.take(loads); + const next = yield* Effect.raceFirst( + Queue.take(h.requests).pipe(Effect.as("sent again" as const)), + h.disconnect.pipe( + Effect.andThen(h.reconnect()), + Effect.andThen(Queue.take(loads)), + Effect.as("quiet" as const), + ), + ); + expect(next).toBe("quiet"); + expect(h.registry.get(pending).size).toBe(0); + }), + ); + + it.effect("does not send a queued change it cannot record as sent", () => + Effect.gen(function* () { + const markFailed = yield* Deferred.make(); + let failMark = true; + const h = yield* makeHarness({ + connected: false, + wrapStore: (store) => ({ + ...store, + markAttempted: (entry) => + failMark + ? Deferred.succeed(markFailed, undefined).pipe( + Effect.andThen( + Effect.fail(new ThreadTriageOutboxStorageError({ cause: "disk full" })), + ), + ) + : store.markAttempted(entry), + }), + }); + yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + yield* h.reconnect(); + const first = yield* Effect.raceFirst( + Deferred.await(markFailed).pipe(Effect.as("not sent" as const)), + Queue.take(h.requests).pipe(Effect.as("sent" as const)), + ); + expect(first).toBe("not sent"); + failMark = false; + // A second reconnect only replays after the first attempt finished. + yield* h.disconnect; + yield* h.reconnect(); + const request = yield* Queue.take(h.requests); + expect(request.command.type).toBe("thread.settle"); + }), + ); + it.effect("forgets an environment's queued changes in memory when it is removed", () => Effect.gen(function* () { const h = yield* makeHarness({ connected: false }); diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index 46cc0fbb11c53..df8c665253b13 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -219,7 +219,10 @@ export function createThreadTriageOutbox(input: { const entries = transform(document.entries); if (entries !== document.entries) registry.set(documentAtom, { entries }); }; + /** Counts removals, so a queue write that lands after one is undone. */ + const removals = new Map(); const clearLocal = (registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId) => { + removals.set(environmentId, (removals.get(environmentId) ?? 0) + 1); if (registry.get(documentAtom) === null) return; setLocal(registry, (entries) => entries.filter((entry) => entry.environmentId !== environmentId), @@ -245,6 +248,8 @@ export function createThreadTriageOutbox(input: { return removed; }; + /** Answered this session; storage that failed to drop one must not make replay send it again. */ + const answered = new Set(); const replaying = new Set(); const replayAgain = new Set(); const isLive = (registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId) => @@ -275,7 +280,9 @@ export function createThreadTriageOutbox(input: { // Storage decides what to send: another tab may have queued, replaced, or cancelled a change. const loaded = await store.run(registry, { _tag: "refresh" }); if (lifetime.ended || !AsyncResult.isSuccess(loaded)) return; - const stored = loaded.value.entries.filter((entry) => entry.environmentId === environmentId); + const stored = loaded.value.entries.filter( + (entry) => entry.environmentId === environmentId && !answered.has(entry.commandId), + ); setLocal(registry, (entries) => { const kept = entries.filter( (entry) => @@ -290,6 +297,7 @@ export function createThreadTriageOutbox(input: { entry.action.type === "snooze" && !(Date.parse(entry.action.snoozedUntil) > DateTime.toEpochMillis(DateTime.nowUnsafe())) ) { + answered.add(entry.commandId); await forget(registry, entry); if (lifetime.ended) return; continue; @@ -305,6 +313,7 @@ export function createThreadTriageOutbox(input: { const result = await input.replay(registry, entry); if (lifetime.ended) return; if (!AsyncResult.isSuccess(result) && isTransient(result.cause)) return; + answered.add(entry.commandId); await forget(registry, entry); if (lifetime.ended) return; } @@ -360,6 +369,7 @@ export function createThreadTriageOutbox(input: { run: async (registry, target) => { const { environmentId } = target; const key = { environmentId, threadId: target.input.threadId }; + const removalsBefore = removals.get(environmentId) ?? 0; // The server compares against what the user saw when acting, not what arrives later. const snapshot = registry.get(input.sourceSnapshotAtom(environmentId)); const thread = snapshot?.threads.find((candidate) => candidate.id === key.threadId); @@ -424,6 +434,10 @@ export function createThreadTriageOutbox(input: { : result; } const entry = queued.value.entries[0]!; + if ((removals.get(environmentId) ?? 0) !== removalsBefore) { + await forget(registry, entry); + return result; + } setLocal(registry, (entries) => [ ...entries.filter((existing) => !sameThreadTriage(existing, entry)), entry, From 881b7df905404a25c3d465356dc158e088aefeb1 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:20:18 +0000 Subject: [PATCH 34/44] test(client-runtime): prove replay never sends a change it could not mark The case now checks the stored entry is marked when the request arrives, so a send that skipped a failed mark can no longer pass it. Co-Authored-By: Claude Opus 5.5 --- packages/client-runtime/src/state/threadCommands.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index b84ce7e87939e..94805f3e1b3b4 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -1118,17 +1118,16 @@ describe("settle and snooze while the environment is offline", () => { }); yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); yield* h.reconnect(); - const first = yield* Effect.raceFirst( - Deferred.await(markFailed).pipe(Effect.as("not sent" as const)), - Queue.take(h.requests).pipe(Effect.as("sent" as const)), - ); - expect(first).toBe("not sent"); + yield* Deferred.await(markFailed); failMark = false; // A second reconnect only replays after the first attempt finished. yield* h.disconnect; yield* h.reconnect(); const request = yield* Queue.take(h.requests); expect(request.command.type).toBe("thread.settle"); + // Only a send that follows a recorded mark finds the entry marked. + expect(stored(h.storage)).toMatchObject([{ attempted: true }]); + expect(yield* Queue.size(h.requests)).toBe(0); }), ); From aa82609a25583a809c563617fc867820501cfab2 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:30:03 +0000 Subject: [PATCH 35/44] fix(client-runtime): leave a change unmarked when the server cannot replay it Replay now asks the connected server for the version guard before marking anything as sent. A server without it refuses before dispatch, so the change stays unmarked and an offline Undo can still cancel it on the device. Co-Authored-By: Claude Opus 5.5 --- packages/client-runtime/src/operations/commands.ts | 2 +- .../client-runtime/src/state/threadCommands.test.ts | 11 ++++++++++- packages/client-runtime/src/state/threadCommands.ts | 7 +++++++ .../client-runtime/src/state/threadTriageOutbox.ts | 9 +++++++++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index c9f07a4b6c58a..ff865aab2765c 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -294,7 +294,7 @@ export class ThreadVersionGuardUnsupportedError extends Schema.TaggedError { yield* Queue.take(h.configReads); expect(yield* Queue.size(h.requests)).toBe(0); expect(h.registry.get(pending).has(THREAD_ID)).toBe(true); - expect(stored(h.storage)).toHaveLength(1); + // Never sent, so it stays unmarked and Undo still cancels it on the device. + expect(stored(h.storage)[0]).not.toHaveProperty("attempted"); + yield* h.disconnect; + const undo = yield* Effect.promise(() => + h.commands.unsettle.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, reason: "user" }, + }), + ); + expect(undo._tag === "Success" && ThreadUndo.cancelledPendingSync(undo.value)).toBe(true); }), ); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index cc8c54c134acc..f85d62d0ebf4c 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -49,6 +49,7 @@ import { reorderPinnedThread, reorderActiveThread, setThreadAutoSettle, + requireThreadVersionGuard, settleThread, snoozeThread, startThreadTurn, @@ -428,6 +429,10 @@ export function createThreadEnvironmentAtoms( concurrency, }), }; + const versionGuard = createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:version-guard", + execute: () => requireThreadVersionGuard(), + }); const triage = createThreadTriageOutbox({ runtime, sourceSnapshotAtom: snapshotAtom, @@ -438,6 +443,8 @@ export function createThreadEnvironmentAtoms( applySettle: (thread, queuedAt) => applySettle(thread, queuedAt, false), applySnooze: (thread, snoozedUntil, queuedAt) => applySnooze(thread, snoozedUntil, queuedAt, false), + checkServer: (registry, environmentId) => + versionGuard.run(registry, { environmentId, input: undefined }), replay: (registry, { environmentId, threadId, commandId, action, snapshotSequence }) => action.type === "settle" ? settle.run(registry, { environmentId, input: { threadId, commandId, snapshotSequence } }) diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index df8c665253b13..f040202f94aa4 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -107,6 +107,11 @@ export function createThreadTriageOutbox(input: { snoozedUntil: string, queuedAt: DateTime.Utc, ) => OrchestrationV2ThreadShell; + /** Fails before anything is sent when the connected server cannot check the snapshot. */ + readonly checkServer: ( + registry: AtomRegistry.AtomRegistry, + environmentId: EnvironmentId, + ) => Promise>; /** Sends the queued action with its command id and snapshot sequence. */ readonly replay: ( registry: AtomRegistry.AtomRegistry, @@ -292,6 +297,10 @@ export function createThreadTriageOutbox(input: { const added = stored.filter((entry) => !entries.some((current) => sameEntry(current, entry))); return kept.length === entries.length && added.length === 0 ? entries : [...kept, ...added]; }); + if (stored.length === 0) return; + // An older server would refuse every entry, which must stay unmarked so Undo can cancel it. + const server = await input.checkServer(registry, environmentId); + if (lifetime.ended || !AsyncResult.isSuccess(server)) return; for (const entry of stored) { if ( entry.action.type === "snooze" && From 28f69a9fecdbb0be1ae8aad2c301551ade74a0d2 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:46:45 +0000 Subject: [PATCH 36/44] fix(web,mobile): announce pending sync and keep outbox logs bounded A thread row's accessible label now says it is pending or waiting to sync, as the visible label does. Offline-queue warnings log a fixed reason instead of the storage or decode error text, which can quote stored thread ids. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/thread-list-v2-items.tsx | 2 +- .../components/sidebar/SidebarThreadRow.tsx | 2 +- apps/web/src/connection/platform.ts | 7 ++++-- .../src/platform/threadTriageOutboxStore.ts | 13 ++++------ .../src/state/threadTriageOutbox.ts | 24 +++++++++---------- 5 files changed, 23 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index a883d8f1cbe64..9deabf86d299a 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -753,7 +753,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); const timeLabel = pendingSync ? "Waiting to sync" : props.timeLabel; const isRegeneratingTitle = thread.titleRegeneration != null; - const rowAccessibilityLabel = `${thread.title}${props.hasQueuedMessages ? ", messages queued to send" : ""}${isRegeneratingTitle ? ", regenerating title" : ""}`; + const rowAccessibilityLabel = `${thread.title}${pendingSync ? ", waiting to sync" : ""}${props.hasQueuedMessages ? ", messages queued to send" : ""}${isRegeneratingTitle ? ", regenerating title" : ""}`; const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const handleAcknowledgeWoke = useCallback(() => { diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index a8b1caff96f87..605e04d2afd73 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1027,7 +1027,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const accessibility = resolveSidebarRowAccessibility({ title: thread.title, - statusLabel: topStatus?.label ?? null, + statusLabel: pendingSync ? "Pending" : (topStatus?.label ?? null), projectDisplayName: props.projectDisplayName, isActive: props.isActive, }); diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index bf2161f66c650..6fc584bd83ad0 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -799,8 +799,11 @@ const layerEnvironmentOwnedDataCleanup = Layer.succeed( clearComposerDraftsEnvironment(environmentId); }).pipe( Effect.andThen(browserThreadTriageOutboxStore.clearEnvironment(environmentId)), - Effect.catch((cause) => - Effect.logWarning("Could not clear offline thread changes.", { environmentId, cause }), + Effect.catch(() => + Effect.logWarning("Could not clear offline thread changes.", { + environmentId, + reason: "storage", + }), ), ); }), diff --git a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts index ceed09c3c25ed..276db56065c31 100644 --- a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts +++ b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts @@ -122,13 +122,14 @@ export function makeThreadTriageOutboxStore(storage: { const clearedListeners = new Set<(environmentId: EnvironmentId) => void>(); const parse = (raw: string | null) => raw === null ? Exit.succeed(EMPTY_DOCUMENT) : decodeStored(raw); - const discardUnreadable = (cause: unknown) => - Effect.logWarning("Discarding unreadable offline thread changes.", { cause: String(cause) }); + const discardUnreadable = Effect.logWarning("Discarding unreadable offline thread changes.", { + reason: "decode", + }); const load = storage.read.pipe( Effect.flatMap((raw) => Exit.match(parse(raw), { onSuccess: Effect.succeed, - onFailure: (cause) => discardUnreadable(cause).pipe(Effect.as(EMPTY_DOCUMENT)), + onFailure: () => discardUnreadable.pipe(Effect.as(EMPTY_DOCUMENT)), }), ), ); @@ -146,11 +147,7 @@ export function makeThreadTriageOutboxStore(storage: { const next = transform(Exit.isSuccess(parsed) ? parsed.value : EMPTY_DOCUMENT); return next === null ? null : encodeStored(next); }) - .pipe( - Effect.tap(() => - Exit.isFailure(parsed) ? discardUnreadable(parsed.cause) : Effect.void, - ), - ); + .pipe(Effect.tap(() => (Exit.isFailure(parsed) ? discardUnreadable : Effect.void))); }).pipe(Effect.ensuring(after)), ); const updateEntries = ( diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index f040202f94aa4..1257f14af110e 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -128,18 +128,16 @@ export function createThreadTriageOutbox(input: { const logFailure = (message: string) => (effect: Effect.Effect) => - effect.pipe( - Effect.tapError((error) => Effect.logWarning(message, { cause: String(error.cause) })), - ); + effect.pipe(Effect.tapError(() => Effect.logWarning(message, { reason: "storage" }))); switch (operation._tag) { case "refresh": return yield* outbox.load; case "load": outbox.subscribeCleared((environmentId) => clearLocal(registry, environmentId)); return yield* outbox.load.pipe( - Effect.catch((error) => + Effect.catch(() => Effect.logWarning("Could not load offline thread changes.", { - cause: String(error.cause), + reason: "storage", }).pipe(Effect.as(EMPTY_DOCUMENT)), ), ); @@ -154,7 +152,7 @@ export function createThreadTriageOutbox(input: { error._tag === "ThreadTriagePendingSettleError" ? Effect.void : Effect.logWarning("Could not save an offline thread change.", { - cause: String(error.cause), + reason: "storage", }), ), ); @@ -179,13 +177,13 @@ export function createThreadTriageOutbox(input: { label: "environment-data:thread-triage-outbox-cancel", execute: Effect.fn("ThreadTriageOutbox.cancel")(function* (entry: PendingThreadTriage) { const outbox = yield* ThreadTriageOutboxStore; - return yield* outbox.cancel(entry).pipe( - Effect.tapError((error) => - Effect.logWarning("Could not cancel an offline thread change.", { - cause: String(error.cause), - }), - ), - ); + return yield* outbox + .cancel(entry) + .pipe( + Effect.tapError(() => + Effect.logWarning("Could not cancel an offline thread change.", { reason: "storage" }), + ), + ); }), }); From 0a9cd7573a83919701901bccd461c509ab3bcabe Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 03:38:33 +0000 Subject: [PATCH 37/44] refactor: queue offline settle and snooze on the client only The queue no longer needs anything from the server. When the environment is back, each queued change is compared with the synchronized shell: if the thread differs from what the user acted on, the server's state wins and the change is dropped. That replaces the snapshotSequence command field, the server guard, the capability flag and the event store filter. Storage is one document with a single update operation, kept in localStorage on web. Memory mirrors what the last write returned, and a change is confirmed in storage right before it is sent. Settle, snooze and their undo are offered while the environment's session has not loaded, through one predicate per app. Co-Authored-By: Claude Fable 5.2 --- apps/mobile/src/connection/platform.ts | 9 +- .../home/useThreadListActions.test.ts | 153 ++-- .../src/features/home/useThreadListActions.ts | 13 +- .../features/threads/ThreadDetailScreen.tsx | 7 +- .../features/threads/thread-list-v2-items.tsx | 16 +- apps/mobile/src/state/session.ts | 29 + .../src/state/thread-triage-outbox-storage.ts | 34 +- apps/mobile/src/state/threads.ts | 7 +- .../src/environment/ServerEnvironment.ts | 1 - apps/server/src/orchestration-v2/EventSink.ts | 1 - .../server/src/orchestration-v2/EventStore.ts | 21 +- .../src/orchestration-v2/Orchestrator.ts | 23 - .../src/orchestration-v2/runtimeLayer.test.ts | 127 --- .../persistence/OrchestrationEventStore.ts | 3 - apps/web/src/components/Sidebar.tsx | 12 +- .../components/chat/useThreadLifecycle.tsx | 13 +- .../components/sidebar/SidebarThreadRow.tsx | 21 +- .../components/threadActionMenu.logic.test.ts | 15 +- .../src/components/threadActionMenu.logic.ts | 12 +- apps/web/src/connection/platform.ts | 8 +- .../threadTriageOutboxStore.browser.test.ts | 68 -- .../src/connection/threadTriageOutboxStore.ts | 88 +- .../web/src/hooks/useThreadActionMenu.test.ts | 8 +- apps/web/src/hooks/useThreadActionMenu.ts | 7 +- .../useThreadActions.permissions.test.ts | 18 +- apps/web/src/hooks/useThreadActions.ts | 4 +- .../src/hooks/useThreadActions.undo.test.ts | 4 - apps/web/src/state/session.ts | 29 + apps/web/src/state/threads.ts | 25 +- .../src/state/use-orchestration-command.ts | 4 +- docs/user/thread-sidebar.md | 12 +- .../client-runtime/src/operations/commands.ts | 37 +- .../src/platform/threadTriageOutboxStore.ts | 229 ++---- packages/client-runtime/src/state/session.ts | 19 +- .../src/state/threadCommands.test.ts | 773 +++++------------- .../src/state/threadCommands.ts | 43 +- .../src/state/threadTriageOutbox.ts | 490 ++++------- packages/contracts/src/environment.ts | 2 - packages/contracts/src/orchestrationV2.ts | 4 - 39 files changed, 632 insertions(+), 1757 deletions(-) delete mode 100644 apps/web/src/connection/threadTriageOutboxStore.browser.test.ts diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index d24248e3a2904..8ed62f641a339 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -265,16 +265,9 @@ const layerEnvironmentOwnedDataCleanup = Layer.succeed( Effect.promise(() => clearThreadOutboxEnvironment(environmentId)), Effect.promise(() => clearComposerDraftsEnvironment(environmentId)), Effect.sync(() => clearThreadComposerErrorsForEnvironment(environmentId)), - mobileThreadTriageOutboxStore.clearEnvironment(environmentId), + Effect.ignore(mobileThreadTriageOutboxStore.clearEnvironment(environmentId)), ], { concurrency: "unbounded", discard: true }, - ).pipe( - Effect.catch((cause) => - Effect.logWarning("Could not clear mobile environment-owned data.", { - environmentId, - cause, - }), - ), ), ), }), diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 14e6a7a2d5597..ccaef5c3b5547 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -19,7 +19,7 @@ const state = vi.hoisted(() => ({ failedAction: null as string | null, archivedRefreshes: [] as string[], scopes: new Map>(), - offlineTriage: new Set(), + sessionNotLoaded: new Set(), successValues: new Map(), shells: [] as EnvironmentThreadShell[], requests: [] as { @@ -62,6 +62,9 @@ vi.mock("../archive/useArchivedThreadSnapshots", () => ({ vi.mock("../../state/session", () => ({ readEnvironmentScope: (environmentId: string, scope: string) => state.scopes.get(environmentId)?.has(scope) === true, + readThreadTriageScope: (environmentId: string) => + state.scopes.get(environmentId)?.has(AuthOrchestrationOperateScope) === true || + state.sessionNotLoaded.has(environmentId), })); vi.mock("../../state/presentation", () => ({ connectedEnvironmentIdsAtom: "connected-environments", @@ -77,32 +80,28 @@ vi.mock("../../state/atom-registry", () => ({ get: (atom: string) => atom === "connected-environments" ? state.connectedEnvironmentIds - : atom.startsWith("offline-triage:") - ? state.offlineTriage.has(atom.slice("offline-triage:".length)) - : atom.startsWith("pending-sync:") - ? new Set() - : atom === "thread-drop-busy" - ? state.dropBusy - : atom === "thread-shells" - ? state.shells - : atom === "queued-thread-keys" - ? new Set() - : new Map( - [...state.scopes.keys()].map((environmentId) => [ - environmentId, - { - environment: { - capabilities: { - threadSettlement: true, - threadSnooze: true, - threadPinning: true, - threadPinReorder: true, - threadTitleRegeneration: true, - }, - }, + : atom === "thread-drop-busy" + ? state.dropBusy + : atom === "thread-shells" + ? state.shells + : atom === "queued-thread-keys" + ? new Set() + : new Map( + [...state.scopes.keys()].map((environmentId) => [ + environmentId, + { + environment: { + capabilities: { + threadSettlement: true, + threadSnooze: true, + threadPinning: true, + threadPinReorder: true, + threadTitleRegeneration: true, }, - ]), - ), + }, + }, + ]), + ), }, })); vi.mock("../../state/use-atom-command", () => ({ @@ -130,45 +129,41 @@ vi.mock("../../state/threads", () => ({ threadShellsAtom: "thread-shells", navigationThreadShellsAtom: "thread-shells", }, - threadEnvironment: { - offlineTriagePermittedAtom: (environmentId: string) => `offline-triage:${environmentId}`, - pendingSyncAtom: (environmentId: string) => `pending-sync:${environmentId}`, - ...Object.fromEntries( - [ - "archive", - "unarchive", - "delete", - "settle", - "unsettle", - "snooze", - "unsnooze", - "pin", - "unpin", - "reorderPin", - "reorderActive", - "updateMetadata", - ].map((action) => [ - action, - async (request: { - environmentId: string; - input: { threadId: string; orderKey?: string }; - }) => { - state.requests.push({ action, ...request }); - const triage = ["settle", "unsettle", "snooze", "unsnooze"].includes(action); - if ( - !state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope) && - !(triage && state.offlineTriage.has(request.environmentId)) - ) { - return AsyncResult.failure(Cause.fail(new Error("Thread operation denied"))); - } - if (action === state.failedAction) - return AsyncResult.failure(Cause.fail(new Error("Server rejected action"))); - state.afterRequest?.(); - return AsyncResult.success(state.successValues.get(action)); - }, - ]), - ), - }, + threadEnvironment: Object.fromEntries( + [ + "archive", + "unarchive", + "delete", + "settle", + "unsettle", + "snooze", + "unsnooze", + "pin", + "unpin", + "reorderPin", + "reorderActive", + "updateMetadata", + ].map((action) => [ + action, + async (request: { + environmentId: string; + input: { threadId: string; orderKey?: string }; + }) => { + state.requests.push({ action, ...request }); + // Without a loaded session the command itself queues settle and snooze. + if ( + !state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope) && + !state.sessionNotLoaded.has(request.environmentId) + ) { + return AsyncResult.failure(Cause.fail(new Error("Thread operation denied"))); + } + if (action === state.failedAction) + return AsyncResult.failure(Cause.fail(new Error("Server rejected action"))); + state.afterRequest?.(); + return AsyncResult.success(state.successValues.get(action)); + }, + ]), + ), })); import { dismissThreadUndoNotice, readThreadUndoNotice } from "./thread-undo-notice"; @@ -205,7 +200,7 @@ beforeEach(() => { state.archivedRefreshes = []; state.pendingOrder = null; state.dropBusy = false; - state.offlineTriage = new Set(); + state.sessionNotLoaded = new Set(); state.successValues = new Map(); state.scopes = new Map([ [primaryEnvironmentId, new Set([AuthOrchestrationOperateScope])], @@ -246,12 +241,11 @@ describe("thread list operation permissions", () => { expect(state.requests).toEqual([]); }); - // Offline, the session may not have loaded; only settle, snooze and their undo may queue. it.each(mutationCases)( - "%s without a loaded session follows the remembered offline triage permission", + "%s runs before the session loads only for settle, snooze and their undo", async (handler, action) => { state.scopes.get(primaryEnvironmentId)!.clear(); - state.offlineTriage.add(primaryEnvironmentId); + state.sessionNotLoaded.add(primaryEnvironmentId); await useThreadListActions()[handler](makeThread(), "2099-01-01T00:00:00.000Z"); @@ -461,30 +455,11 @@ describe("thread action undo", () => { expect(readThreadUndoNotice()).toBeNull(); }); - it("restores snooze with the remembered grant if the session disappears during Undo", async () => { - const thread = makeThread({ snoozedUntil: "2099-01-01T09:00:00.000Z" }); - expect(await useThreadListActions().settleThread(thread)).toBe(true); - state.afterRequest = () => { - state.scopes.delete(primaryEnvironmentId); - state.offlineTriage.add(primaryEnvironmentId); - }; - - await readThreadUndoNotice()!.undo(); - - expect(state.requests.map(({ action }) => action)).toEqual(["settle", "unsettle", "snooze"]); - expect(state.requests.at(-1)?.input).toEqual({ - threadId: thread.id, - snoozedUntil: thread.snoozedUntil, - }); - expect(state.alerts).toEqual([]); - }); - // Whether the queued settle reached the server is known only when Undo runs. it.each([ ["cancels it before it syncs", { sequence: 0, cancelledPendingSync: true }, ["snooze"]], ["finds it delivered", { sequence: 3 }, ["pin", "snooze"]], ])("restores a queued settle only as far as needed when Undo %s", async (_, undone, restored) => { - state.successValues.set("settle", { sequence: 1, pendingSync: true }); state.successValues.set("unsettle", undone); const thread = makeThread({ pinnedAt: "2026-09-02T00:00:00.000Z", @@ -492,10 +467,8 @@ describe("thread action undo", () => { snoozedUntil: "2099-01-01T09:00:00.000Z", }); expect(await useThreadListActions().settleThread(thread)).toBe(true); - expect(readThreadUndoNotice()?.action).toBe("Settled"); await readThreadUndoNotice()!.undo(); expect(state.requests.map(({ action }) => action)).toEqual(["settle", "unsettle", ...restored]); - expect(readThreadUndoNotice()).toBeNull(); }); // regression: #21401 restore archive and thread action undo diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 758fe07dbfe5f..94adc7e16d584 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -16,7 +16,7 @@ import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; import { connectedEnvironmentIdsAtom } from "../../state/presentation"; -import { readEnvironmentScope } from "../../state/session"; +import { readEnvironmentScope, readThreadTriageScope } from "../../state/session"; import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -77,19 +77,18 @@ function actionFailureTitle(action: ThreadListAction): string { return "Could not delete thread"; } -/** Settle, snooze and their undo may queue offline where the last session could change threads. */ +/** Settle, snooze and their undo may queue before the environment's session loads. */ function checkThreadOperationPermission( thread: EnvironmentThreadShell, title: string, triage = false, ): boolean { - if (readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return true; if ( - triage && - appAtomRegistry.get(threadEnvironment.offlineTriagePermittedAtom(thread.environmentId)) - ) { + triage + ? readThreadTriageScope(thread.environmentId) + : readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope) + ) return true; - } Dialog.alert(title, "This connection cannot change threads."); return false; } diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 837dc633ad50f..299b2e6436c1e 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -163,6 +163,7 @@ import { appAtomRegistry } from "../../state/atom-registry"; import { threadStreamHaptics } from "./threadStreamHaptics"; import { useAtomCommand } from "../../state/use-atom-command"; import { orchestrationEnvironment } from "../../state/orchestration"; +import { useThreadTriageScope } from "../../state/session"; import { useDelayedStatus } from "../../lib/useDelayedStatus"; import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; import type { RunStopPresentation } from "@t3tools/client-runtime/state/thread-execution"; @@ -585,11 +586,7 @@ function ThreadDetailBody( }); const unsnoozeThread = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); const unsettleThread = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); - // Offline, wake and un-settle only cancel a change still waiting to sync. - const pendingSync = useAtomValue(threadEnvironment.pendingSyncAtom(props.environmentId)).has( - props.selectedThread.id, - ); - const canTriageThread = props.canOperateThread || pendingSync; + const canTriageThread = useThreadTriageScope(props.environmentId); const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false }); const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const anyRunStop = useAtomValue( diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 124afa7e89cba..6b55c72f68fc5 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -51,7 +51,7 @@ import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { copyThreadReference } from "../../lib/copyThreadReference"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import { useEnvironmentScope } from "../../state/session"; +import { useEnvironmentScope, useThreadTriageScope } from "../../state/session"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { pendingDraftBranchNeedsRefs, @@ -282,7 +282,7 @@ type ThreadListV2ShelfHeaderProps = { readonly pane?: "screen" | "sidebar"; }; -const TRIAGE_MENU_ACTION_IDS = new Set(["settle", "snooze"]); +const TRIAGE_MENU_ACTION_IDS = new Set(["settle", "snooze", "unsettle", "unsnooze"]); const SHELF_LABEL = { working: "Working", snoozed: "Snoozed", settled: "Settled" } as const; @@ -805,15 +805,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); - // Offline, settle and snooze can still queue where the last session could change threads. - const offlineTriagePermitted = useAtomValue( - threadEnvironment.offlineTriagePermittedAtom(thread.environmentId), - ); - const triageOnly = !canOperateThread && offlineTriagePermitted; - // Un-settle and wake only cancel a change still waiting to sync. - const triageAllows = (action: string) => - TRIAGE_MENU_ACTION_IDS.has(action) || - (pendingSync && (action === "unsettle" || action === "unsnooze")); + // Before the session loads, only settle, snooze and their undo are offered: they can queue. + const triageOnly = useThreadTriageScope(thread.environmentId) && !canOperateThread; + const triageAllows = (action: string) => TRIAGE_MENU_ACTION_IDS.has(action); const handleRegenerateTitle = useCallback( () => onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, thread], diff --git a/apps/mobile/src/state/session.ts b/apps/mobile/src/state/session.ts index 5d6a441e9b9e4..4e4e68cc0f052 100644 --- a/apps/mobile/src/state/session.ts +++ b/apps/mobile/src/state/session.ts @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; import { + AuthOrchestrationOperateScope, type AuthEnvironmentScope, type AuthSessionState, type EnvironmentId, @@ -66,6 +67,34 @@ export function readEnvironmentScope( return sessionHasScope(result, scope); } +/** + * Settle and snooze can queue for an environment whose session has not loaded yet, such as one + * that was offline at launch. The server checks the grant when the change is sent. + */ +function sessionAllowsThreadTriage( + result: AsyncResult.AsyncResult, +): boolean { + return ( + !AsyncResult.isSuccess(result) || + sessionGrantsScope(result.value, AuthOrchestrationOperateScope) + ); +} + +export function useThreadTriageScope(environmentId: EnvironmentId | null): boolean { + return useAtomValue( + environmentId === null + ? EMPTY_SESSION_STATE_ATOM + : environmentSession.sessionStateAtom(environmentId), + sessionAllowsThreadTriage, + ); +} + +export function readThreadTriageScope(environmentId: EnvironmentId): boolean { + return sessionAllowsThreadTriage( + appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)), + ); +} + const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe( Atom.withLabel("mobile-prepared-connection:empty"), ); diff --git a/apps/mobile/src/state/thread-triage-outbox-storage.ts b/apps/mobile/src/state/thread-triage-outbox-storage.ts index 9fc844eef65b4..26b531518a9d2 100644 --- a/apps/mobile/src/state/thread-triage-outbox-storage.ts +++ b/apps/mobile/src/state/thread-triage-outbox-storage.ts @@ -6,29 +6,15 @@ import * as Effect from "effect/Effect"; import { writeFileAtomically } from "../lib/atomic-file"; -async function getStorageFile() { - const { File, Paths } = await import("expo-file-system"); - return new File(Paths.document, "thread-triage-outbox.json"); -} - -async function readStorageFile() { - const file = await getStorageFile(); - return file.exists ? await file.text() : null; -} - -/** Lives outside the client cache so clearing cached data keeps unsynced settles and snoozes. */ -export const mobileThreadTriageOutboxStore = makeThreadTriageOutboxStore({ - read: Effect.tryPromise({ - try: readStorageFile, +/** A file outside the client cache, so clearing cached data keeps unsynced settles and snoozes. */ +export const mobileThreadTriageOutboxStore = makeThreadTriageOutboxStore((next) => + Effect.tryPromise({ + try: async () => { + const { File, Paths } = await import("expo-file-system"); + const file = new File(Paths.document, "thread-triage-outbox.json"); + const value = next(file.exists ? await file.text() : null); + if (value !== null) await writeFileAtomically(file, value); + }, catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), }), - // One process owns this file, and the store already serializes its own changes. - modify: (next) => - Effect.tryPromise({ - try: async () => { - const value = next(await readStorageFile()); - if (value !== null) await writeFileAtomically(await getStorageFile(), value); - }, - catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), - }), -}); +); diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 40d2441dd15a5..e288e2f9ad612 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -17,7 +17,6 @@ import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { scopedThreadKey } from "../lib/scopedEntities"; import { appAtomRegistry } from "./atom-registry"; -import { environmentSession } from "./session"; import { pendingThreadCreationOutcomesAtom, selectThreadCreationDetail, @@ -37,11 +36,7 @@ export const olderSettledThreads = createOlderSettledThreadAtoms( export const threadEnvironment = createThreadEnvironmentAtoms( connectionAtomRuntime, threadFamilies.snapshotAtom, - { - shellStatusAtom: environmentShell.statusAtom, - operateScopeAtom: environmentSession.operateScopeAtom, - serverConfigAtom: serverEnvironment.configValueAtom, - }, + environmentShell.statusAtom, ); export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index c5fb8c0cc00b9..9e29b0fdedc95 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -245,7 +245,6 @@ export const make = Effect.gen(function* () { projectSettingsOverrides: true, projectProviderInstanceOverrides: true, threadSnooze: true, - threadTriageVersionGuard: true, threadForceStop: true, environmentThemes: true, usageLimitSources: true, diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index d9d43eec214b4..81e64ddb70920 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -218,7 +218,6 @@ export interface EventSinkV2Shape { }) => Stream.Stream; readonly latestSequence: (input?: { readonly threadId?: ThreadId; - readonly exceptEventTypes?: ReadonlyArray; }) => Effect.Effect; readonly readByCommandId: (input: { readonly commandId: CommandId; diff --git a/apps/server/src/orchestration-v2/EventStore.ts b/apps/server/src/orchestration-v2/EventStore.ts index bfbcb7b824536..e2125508dee8f 100644 --- a/apps/server/src/orchestration-v2/EventStore.ts +++ b/apps/server/src/orchestration-v2/EventStore.ts @@ -67,7 +67,6 @@ export interface EventStoreV2Shape { }) => Stream.Stream; readonly latestSequence: (input?: { readonly threadId?: ThreadId; - readonly exceptEventTypes?: ReadonlyArray; }) => Effect.Effect; readonly latestApplicationSequence: Effect.Effect; readonly publishCommitted: (events: ReadonlyArray) => Effect.Effect; @@ -132,17 +131,15 @@ const layerBase: Layer.Layer new EventStoreReadEventsError({ cause }))), latestSequence: Effect.fn("EventStoreV2.latestSequence")(function (input) { - return applicationEvents - .latestAgentSequence(input?.threadId, input?.exceptEventTypes) - .pipe( - Effect.mapError( - (cause) => - new EventStoreReadEventsError({ - ...(input?.threadId === undefined ? {} : { threadId: input.threadId }), - cause, - }), - ), - ); + return applicationEvents.latestAgentSequence(input?.threadId).pipe( + Effect.mapError( + (cause) => + new EventStoreReadEventsError({ + ...(input?.threadId === undefined ? {} : { threadId: input.threadId }), + cause, + }), + ), + ); }), latestApplicationSequence: applicationEvents.latestApplicationSequence.pipe( Effect.mapError((cause) => new EventStoreReadEventsError({ cause })), diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index a4b86d00f7484..37c4a75f663b6 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3553,29 +3553,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } } - if ( - (command.type === "thread.settle" || command.type === "thread.snooze") && - command.snapshotSequence !== undefined - ) { - const currentSequence = yield* eventSink - .latestSequence({ - threadId: command.threadId, - // Shutdown and restart recovery stop every live session; the user changed nothing. - exceptEventTypes: ["provider-session.updated"], - }) - .pipe( - Effect.mapError( - (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), - ), - ); - if (currentSequence > command.snapshotSequence) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Thread ${command.threadId} changed after the client's snapshot.`, - }); - } - } if (command.type === "thread.settle") { const projection = yield* loadProjectionForCommand(command, ["runs", "runtimeRequests"]); if ( diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 7ecf857189e3a..b9f5cd049eec9 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -7446,133 +7446,6 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => { }), ); - // A client replays settle or snooze it took offline; any change after its cached shell wins. - it.effect( - "rejects a replayed settle or snooze once the thread changed after the client's snapshot", - () => - Effect.gen(function* () { - const projects = yield* ProjectService.ProjectService; - const orchestrator = yield* Orchestrator.OrchestratorV2; - const eventSink = yield* EventSink.EventSinkV2; - const projectId = ProjectId.make("runtime-layer-triage-version-project"); - const threadId = ThreadId.make("runtime-layer-triage-version-thread"); - const snoozedUntil = "2099-07-25T09:00:00.000Z"; - yield* projects.create({ - commandId: CommandId.make("runtime-layer-triage-version-project-create"), - projectId, - title: "Triage version", - workspaceRoot: "/tmp/runtime-layer-triage-version-project", - }); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("runtime-layer-triage-version-thread-create"), - threadId, - projectId, - title: "Triage version thread", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - const versionOf = Effect.fn("versionOf")(function* () { - const shell = yield* orchestrator.getShellSnapshot(); - const thread = shell.threads.find((candidate) => candidate.id === threadId); - assert.isDefined(thread); - return { updatedAt: thread.updatedAt, snapshotSequence: shell.snapshotSequence }; - }); - const rename = (suffix: string) => - orchestrator.dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make(`runtime-layer-triage-version-rename-${suffix}`), - threadId, - title: `Renamed ${suffix}`, - }); - - // Same millisecond: updatedAt cannot tell the snapshots apart, the sequence can. - const cached = yield* versionOf(); - yield* rename("same-ms"); - assert.deepEqual((yield* versionOf()).updatedAt, cached.updatedAt); - const staleSettle = yield* orchestrator - .dispatch({ - type: "thread.settle", - commandId: CommandId.make("runtime-layer-triage-version-settle-stale"), - threadId, - snapshotSequence: cached.snapshotSequence, - }) - .pipe(Effect.flip); - assert.instanceOf(staleSettle, Orchestrator.OrchestratorDispatchError); - - const beforeLaterChange = yield* versionOf(); - yield* TestClock.adjust("1 second"); - yield* rename("later"); - const staleSnooze = yield* orchestrator - .dispatch({ - type: "thread.snooze", - commandId: CommandId.make("runtime-layer-triage-version-snooze-stale"), - threadId, - snoozedUntil, - snapshotSequence: beforeLaterChange.snapshotSequence, - }) - .pipe(Effect.flip); - assert.instanceOf(staleSnooze, Orchestrator.OrchestratorDispatchError); - const untouched = (yield* orchestrator.getThreadProjection(threadId)).thread; - assert.isNull(untouched.settledOverride); - assert.isNull(untouched.snoozedUntil ?? null); - - // Shutdown and restart recovery stop live sessions; that is not a change the user made. - const now = yield* DateTime.now; - const session = { - id: ProviderSessionId.make("runtime-layer-triage-version-session"), - driver, - providerInstanceId: modelSelection.instanceId, - status: "ready" as const, - cwd: "/tmp/runtime-layer-triage-version-project", - model: modelSelection.model, - capabilities: CodexProviderCapabilitiesV2, - createdAt: now, - updatedAt: now, - lastError: null, - }; - const sessionEvent = (type: "provider-session.attached" | "provider-session.updated") => - eventSink.write({ - commandId: CommandId.make(`runtime-layer-triage-version-${type}`), - events: [ - { - id: EventId.make(`runtime-layer-triage-version-${type}`), - type, - threadId, - driver, - providerInstanceId: session.providerInstanceId, - occurredAt: now, - payload: - type === "provider-session.attached" - ? session - : { ...session, status: "stopped" }, - }, - ], - }); - yield* sessionEvent("provider-session.attached"); - const current = yield* versionOf(); - yield* sessionEvent("provider-session.updated"); - const settle = { - type: "thread.settle" as const, - commandId: CommandId.make("runtime-layer-triage-version-settle"), - threadId, - snapshotSequence: current.snapshotSequence, - }; - const accepted = yield* orchestrator.dispatch(settle); - assert.equal( - (yield* orchestrator.getThreadProjection(threadId)).thread.settledOverride, - "settled", - ); - // A retry after a lost acknowledgement replays the receipt instead of failing the guard. - assert.equal((yield* orchestrator.dispatch(settle)).sequence, accepted.sequence); - }), - ); - it.effect("moves a thread into a project rooted at its own folder", () => Effect.gen(function* () { const projects = yield* ProjectService.ProjectService; diff --git a/apps/server/src/persistence/OrchestrationEventStore.ts b/apps/server/src/persistence/OrchestrationEventStore.ts index 64bad3ccff9b7..e55d82713871b 100644 --- a/apps/server/src/persistence/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/OrchestrationEventStore.ts @@ -93,7 +93,6 @@ export class OrchestrationEventStore extends Context.Service< readonly latestAgentSequence: ( threadId?: ThreadId, - exceptEventTypes?: ReadonlyArray, ) => Effect.Effect; readonly latestApplicationSequence: Effect.Effect; @@ -647,7 +646,6 @@ const makeEventStore = Effect.gen(function* () { const latestAgentSequence: OrchestrationEventStore["Service"]["latestAgentSequence"] = ( threadId, - exceptEventTypes = [], ) => sql<{ readonly sequence: number | null }>` SELECT MAX(sequence) AS sequence @@ -661,7 +659,6 @@ const makeEventStore = Effect.gen(function* () { WHERE application_event_version = 2 AND aggregate_kind = 'thread' ${threadId === undefined ? sql`` : sql`AND stream_id = ${threadId}`} - ${exceptEventTypes.length === 0 ? sql`` : sql`AND event_type NOT IN ${sql.in(exceptEventTypes)}`} `.pipe( NodeSqliteClient.readOnly, Effect.map((rows) => rows[0]?.sequence ?? 0), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2d93422af03db..90db326c7129f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -156,11 +156,11 @@ import { import { environmentPresentations } from "../state/presentation"; import { primaryServerKeybindingsAtom } from "../state/server"; import { environmentProjects } from "../state/projects"; -import { readThreadPendingSync, readThreadTriageScope, threadEnvironment } from "../state/threads"; +import { threadEnvironment } from "../state/threads"; import { useOlderSettledShelf, useOlderSettledShelfGrowth } from "../state/olderSettledShelf"; import { useThreadSearch } from "../state/queries"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; -import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; +import { readEnvironmentScope, readThreadTriageScope, useEnvironmentScope } from "../state/session"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { canShareWithGuestAtom } from "./chat/guestShareAvailability"; import { openGuestShareDialog } from "./chat/guestShareDialogState"; @@ -858,7 +858,7 @@ function ProjectScopeRowIcon(props: { isSelected: boolean; children: ReactNode } ); } -/** Settle, snooze and their undo can queue offline where the last session could operate. */ +/** Settle, snooze and their undo can queue before the environment's session loads. */ function canOperateThreads( threads: ReadonlyArray>, triage = false, @@ -2128,7 +2128,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const thread = threadByKey.get(key); return thread ? [thread] : []; }), - plan.kind === "settle", ) ) return; @@ -2638,10 +2637,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve threadRef.environmentId, AuthOrchestrationOperateScope, ), - canTriageOffline: appAtomRegistry.get( - threadEnvironment.offlineTriagePermittedAtom(threadRef.environmentId), - ), - hasPendingSync: readThreadPendingSync(threadRef.environmentId, threadRef.threadId), + canTriage: readThreadTriageScope(threadRef.environmentId), branch: thread.branch ?? null, // An archived project is not a filter choice. projectFilter: diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index 79ce0bc6db2ac..a00227f827cf4 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -52,8 +52,8 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; import { newMessageId } from "~/lib/utils"; import { buildThreadRouteParams } from "../../threadRoutes"; import { composerDraftHasUserContent, useComposerDraftStore } from "../../composerDraftStore"; -import { useEnvironmentScope } from "~/state/session"; -import { readThreadTriageScope, threadEnvironment } from "../../state/threads"; +import { useEnvironmentScope, useThreadTriageScope } from "~/state/session"; +import { threadEnvironment } from "../../state/threads"; import { vcsEnvironment } from "../../state/vcs"; import { useChildThreadInputs, readThreadShell, waitForThreadShell } from "../../state/entities"; import { serverEnvironment } from "../../state/server"; @@ -61,7 +61,7 @@ import { environmentShell } from "../../state/shell"; import { ThreadStatusLine } from "./ThreadStatusLine"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { appAtomRegistry } from "../../rpc/atomRegistry"; -import { readEnvironmentScope } from "../../state/session"; +import { readEnvironmentScope, readThreadTriageScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { useOrchestrationCommand } from "../../state/use-orchestration-command"; import { @@ -179,10 +179,7 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { const router = useRouter(); const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); - const pendingSync = useAtomValue(threadEnvironment.pendingSyncAtom(environmentId)); - // Offline, wake and un-settle only cancel a change still waiting to sync. - const canTriageThread = - canOperateThread || (activeThreadId !== null && pendingSync.has(activeThreadId)); + const canTriageThread = useThreadTriageScope(environmentId); const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, @@ -658,7 +655,7 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { if (!readThreadTriageScope(environmentId)) return false; if (!activeThreadShell || !activeThreadRef || !serverConfigLoaded) return; if (activeThreadSettled) { - if (canTriageThread) void handleUnsettleActiveThread(); + void handleUnsettleActiveThread(); return; } if (settleAndAdvance(activeThreadRef)) return; diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 2e4344974925d..07bb5e74804b3 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -73,7 +73,11 @@ import { useNowSecond } from "../../hooks/useNowSecond"; import { vcsEnvironment, vcsBranchRevalidation } from "../../state/vcs"; import { useEnvironmentQuery } from "../../state/query"; import { threadEnvironment } from "../../state/threads"; -import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; +import { + readEnvironmentScope, + useEnvironmentScope, + useThreadTriageScope, +} from "../../state/session"; import { formatRelativeTimeLabel, parseTimestampDate } from "../../timestampFormat"; import type { SidebarThreadSummary } from "../../types"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; @@ -468,14 +472,10 @@ export function SortableThreadRow( rowProps.thread.environmentId, AuthOrchestrationOperateScope, ); - // Offline, a drop onto Settled can still queue; the drop handler keeps other moves live-only. - const canTriageThread = useAtomValue( - threadEnvironment.offlineTriagePermittedAtom(rowProps.thread.environmentId), - ); const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: sortableId, disabled: - sortableDisabled || !(canOperateThread || canTriageThread) + sortableDisabled || !canOperateThread ? SIDEBAR_SORTABLE_DRAG_DISABLED : SIDEBAR_SORTABLE_DRAG_ENABLED, animateLayoutChanges: animateSidebarLayoutChanges, @@ -671,10 +671,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); - // Offline, snooze can still queue where the last session could change threads. - const offlineTriagePermitted = useAtomValue( - threadEnvironment.offlineTriagePermittedAtom(thread.environmentId), - ); + const canTriageThread = useThreadTriageScope(thread.environmentId); const pendingSync = useAtomValue(threadEnvironment.pendingSyncAtom(thread.environmentId)).has( thread.id, ); @@ -960,9 +957,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP setSnoozeGateClock({ thread, now: new Date(snoozeGateOpensAt).toISOString() }); } const showSnoozeButton = - (canOperateThread || offlineTriagePermitted) && - props.snoozeSupported && - canSnooze(thread, { now: snoozeGateNow }); + canTriageThread && props.snoozeSupported && canSnooze(thread, { now: snoozeGateNow }); // Closing here, not just masking, keeps the menu from reopening when the button returns. if (snoozeMenuOpenRaw && !showSnoozeButton) setSnoozeMenuOpen(false); const snoozeMenuOpen = snoozeMenuOpenRaw && showSnoozeButton; diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index dd0a92da59085..622f101672139 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -65,26 +65,19 @@ describe("buildThreadActionMenuItems", () => { }, ); - // Offline, un-settle and wake can only cancel a change still waiting to sync. - it.each([ - [false, false], - [true, false], - [true, true], - ])( - "keeps only settle and snooze directions offline where triage is remembered (reversed: %s, pending: %s)", - (reversed, pending) => { + it.each([false, true])( + "keeps settle, snooze and their undo before the session loads (reversed: %s)", + (reversed) => { const items = buildThreadActionMenuItems({ ...baseState, canOperate: false, - canTriageOffline: true, - hasPendingSync: pending, + canTriage: true, isPinned: reversed, isSettled: reversed, isSnoozed: reversed, }); expect(items.filter((item) => item.disabled).map((item) => item.id)).toEqual([ reversed ? "unpin" : "pin", - ...(reversed && !pending ? ["unsettle", "unsnooze"] : []), "rename", "regenerate-title", "auto-settle", diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 7003855291823..03b8a081466fc 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -69,10 +69,8 @@ export function buildDraftActionMenuItems(options: { export interface ThreadActionMenuState { readonly canOperate: boolean; - /** Offline, settle and snooze can queue where the last session could operate. */ - readonly canTriageOffline?: boolean; - /** Offline, un-settle and wake only cancel a change still waiting to sync. */ - readonly hasPendingSync?: boolean; + /** Settle, snooze and their undo can queue before the environment's session loads. */ + readonly canTriage?: boolean; readonly canFind?: boolean; readonly branch: string | null; readonly projectFilter: { @@ -270,11 +268,7 @@ export function buildThreadActionMenuItems( ? items : items.map((item) => threadActionRequiresOperate(item.id) && - !( - state.canTriageOffline === true && - isThreadTriageAction(item.id) && - (state.hasPendingSync === true || (item.id !== "unsettle" && item.id !== "unsnooze")) - ) + !(state.canTriage === true && isThreadTriageAction(item.id)) ? { ...item, disabled: true, diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 6fc584bd83ad0..cf171c6083c7a 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -798,12 +798,8 @@ const layerEnvironmentOwnedDataCleanup = Layer.succeed( return Effect.sync(() => { clearComposerDraftsEnvironment(environmentId); }).pipe( - Effect.andThen(browserThreadTriageOutboxStore.clearEnvironment(environmentId)), - Effect.catch(() => - Effect.logWarning("Could not clear offline thread changes.", { - environmentId, - reason: "storage", - }), + Effect.andThen( + Effect.ignore(browserThreadTriageOutboxStore.clearEnvironment(environmentId)), ), ); }), diff --git a/apps/web/src/connection/threadTriageOutboxStore.browser.test.ts b/apps/web/src/connection/threadTriageOutboxStore.browser.test.ts deleted file mode 100644 index 0cb80297cedc9..0000000000000 --- a/apps/web/src/connection/threadTriageOutboxStore.browser.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { CommandId, EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { - makeThreadTriageOutboxStore, - ThreadTriageOutboxStorageError, -} from "@t3tools/client-runtime/platform"; -import * as Cause from "effect/Cause"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import { vi } from "vite-plus/test"; - -import { - browserThreadTriageOutboxStorage, - browserThreadTriageOutboxStore, -} from "./threadTriageOutboxStore"; - -const settle = (threadId: string) => ({ - environmentId: EnvironmentId.make("remote"), - threadId: ThreadId.make(threadId), - commandId: CommandId.make(`settle-${threadId}`), - action: { type: "settle" as const }, - queuedAt: DateTime.makeUnsafe("2026-09-12T10:00:00.000Z"), - snapshotSequence: 1, -}); - -const replaceStored = (raw: string) => browserThreadTriageOutboxStorage.modify(() => raw); - -describe("browser thread triage storage", () => { - it.effect("keeps every tab's change when tabs save at the same time", () => - Effect.gen(function* () { - yield* replaceStored(JSON.stringify({ entries: [] })); - const tab = () => makeThreadTriageOutboxStore(browserThreadTriageOutboxStorage); - - yield* Effect.all([tab().put(settle("first")), tab().put(settle("second"))], { - concurrency: "unbounded", - }); - - const { entries } = yield* tab().load; - expect(entries.map((entry) => entry.threadId).toSorted()).toEqual(["first", "second"]); - }), - ); - - it.effect("clears retained environment state even when storage is unavailable", () => - Effect.gen(function* () { - yield* Effect.acquireRelease( - Effect.sync(() => vi.stubGlobal("indexedDB", undefined)), - () => Effect.sync(() => vi.unstubAllGlobals()), - ); - const environmentId = EnvironmentId.make("removed"); - const retained = new Set([environmentId]); - yield* Effect.acquireRelease( - Effect.sync(() => - browserThreadTriageOutboxStore.subscribeCleared((removed) => retained.delete(removed)), - ), - (unsubscribe) => Effect.sync(unsubscribe), - ); - - const result = yield* Effect.exit( - browserThreadTriageOutboxStore.clearEnvironment(environmentId), - ); - - expect(result._tag === "Failure" && Cause.squash(result.cause)).toBeInstanceOf( - ThreadTriageOutboxStorageError, - ); - expect(retained.size).toBe(0); - }), - ); -}); diff --git a/apps/web/src/connection/threadTriageOutboxStore.ts b/apps/web/src/connection/threadTriageOutboxStore.ts index 15cc219b18f6d..2177ab3d441bf 100644 --- a/apps/web/src/connection/threadTriageOutboxStore.ts +++ b/apps/web/src/connection/threadTriageOutboxStore.ts @@ -4,85 +4,15 @@ import { } from "@t3tools/client-runtime/platform"; import * as Effect from "effect/Effect"; -const DATABASE_NAME = "t3code-thread-triage-outbox"; -const STORE_NAME = "outbox"; -const KEY = "document"; +const KEY = "t3code:thread-triage-outbox"; -const openDatabase = Effect.callback((resume) => { - try { - const request = indexedDB.open(DATABASE_NAME, 1); - request.addEventListener("upgradeneeded", () => request.result.createObjectStore(STORE_NAME)); - request.addEventListener("error", () => - resume(Effect.fail(new ThreadTriageOutboxStorageError({ cause: request.error }))), - ); - request.addEventListener("success", () => resume(Effect.succeed(request.result))); - } catch (cause) { - resume(Effect.fail(new ThreadTriageOutboxStorageError({ cause }))); - } -}); - -/** Succeeds once the transaction commits; `abort` rolls it back and fails with that cause. */ -const transact = ( - mode: IDBTransactionMode, - run: (store: IDBObjectStore, abort: (cause: unknown) => void) => void, -) => - Effect.acquireUseRelease( - openDatabase, - (database) => - Effect.callback((resume) => { - let failure: unknown; - try { - const transaction = database.transaction(STORE_NAME, mode); - transaction.addEventListener("complete", () => resume(Effect.void)); - transaction.addEventListener("abort", () => - resume( - Effect.fail( - new ThreadTriageOutboxStorageError({ cause: failure ?? transaction.error }), - ), - ), - ); - run(transaction.objectStore(STORE_NAME), (cause) => { - failure = cause; - transaction.abort(); - }); - } catch (cause) { - resume(Effect.fail(new ThreadTriageOutboxStorageError({ cause }))); - } - }), - (database) => Effect.sync(() => database.close()), - ); - -const stored = (value: unknown) => (typeof value === "string" ? value : null); - -/** - * Kept out of the environment cache so clearing cached data keeps unsynced settles and snoozes. - * Browsers run readwrite transactions on one store in order across tabs, so concurrent changes - * from several tabs all land, on plain HTTP as well as secure origins. - */ -export const browserThreadTriageOutboxStorage = { - read: Effect.suspend(() => { - let value: string | null = null; - return transact("readonly", (store) => { - const request = store.get(KEY); - request.addEventListener("success", () => { - value = stored(request.result); - }); - }).pipe(Effect.map(() => value)); +/** In localStorage, so tabs share it and clearing the environment cache keeps unsynced changes. */ +export const browserThreadTriageOutboxStore = makeThreadTriageOutboxStore((next) => + Effect.try({ + try: () => { + const value = next(window.localStorage.getItem(KEY)); + if (value !== null) window.localStorage.setItem(KEY, value); + }, + catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), }), - modify: (next) => - transact("readwrite", (store, abort) => { - const request = store.get(KEY); - request.addEventListener("success", () => { - try { - const value = next(stored(request.result)); - if (value !== null) store.put(value, KEY); - } catch (cause) { - abort(cause); - } - }); - }), -} satisfies Parameters[0]; - -export const browserThreadTriageOutboxStore = makeThreadTriageOutboxStore( - browserThreadTriageOutboxStorage, ); diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts index 7ad29f23247f3..564764d1f6b04 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -45,6 +45,7 @@ vi.mock("@tanstack/react-router", () => ({ vi.mock("../state/session", () => ({ readEnvironmentScope: (environmentId: string, scope: string) => scope === AuthOrchestrationOperateScope && state.granted.has(environmentId), + readThreadTriageScope: (environmentId: string) => state.granted.has(environmentId), })); vi.mock("../state/entities", () => ({ readEnvironmentServerConfigLoaded: () => true, @@ -62,12 +63,7 @@ vi.mock("../state/entities", () => ({ useProjects: () => [{ id: "project", environmentId: "secondary" }], })); vi.mock("../state/environments", () => ({ usePrimaryEnvironmentId: () => "primary" })); -vi.mock("../state/threads", () => ({ - // No remembered offline permission here, so triage follows the live grant. - readThreadTriageScope: (environmentId: string) => state.granted.has(environmentId), - readThreadPendingSync: () => false, - threadEnvironment: { updateMetadata: "metadata" }, -})); +vi.mock("../state/threads", () => ({ threadEnvironment: { updateMetadata: "metadata" } })); vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => async () => { recordEffect("metadata"); diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 71bd804d2a792..b31f6e0e680ab 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -25,9 +25,9 @@ import { import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { copyThreadReference } from "../lib/copyThreadReference"; import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; -import { readThreadPendingSync, readThreadTriageScope, threadEnvironment } from "../state/threads"; +import { threadEnvironment } from "../state/threads"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; -import { readEnvironmentScope } from "../state/session"; +import { readEnvironmentScope, readThreadTriageScope } from "../state/session"; import { readEnvironmentServerConfigLoaded, readEnvironmentSupportsAutoSettleOptOut, @@ -136,8 +136,7 @@ export function useThreadActionMenu(input: { const snoozePresets = resolveSnoozePresets(now, timestampFormat, threadRef.environmentId); const items = buildThreadActionMenuItems({ canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), - canTriageOffline: readThreadTriageScope(threadRef.environmentId), - hasPendingSync: readThreadPendingSync(threadRef.environmentId, threadRef.threadId), + canTriage: readThreadTriageScope(threadRef.environmentId), canFind: onFind !== undefined, branch: thread.branch ?? null, projectFilter: null, diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index 19cc97521bca8..03814486ae1d1 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -27,7 +27,7 @@ const state = vi.hoisted(() => ({ serverConfig: { shellThreadFamilies: false }, familyLookupFails: false, scopes: new Map>(), - offlineTriage: new Set(), + sessionNotLoaded: new Set(), threads: [] as { environmentId: EnvironmentId; id: ThreadId; @@ -62,6 +62,9 @@ vi.mock("../state/session", () => ({ environmentSession: { sessionStateAtom: "session" }, readEnvironmentScope: (environmentId: string, scope: string) => state.scopes.get(environmentId)?.has(scope) === true, + readThreadTriageScope: (environmentId: string) => + state.scopes.get(environmentId)?.has(AuthOrchestrationOperateScope) === true || + state.sessionNotLoaded.has(environmentId), })); vi.mock("../state/use-atom-command", () => ({ useAtomCommand: @@ -70,10 +73,10 @@ vi.mock("../state/use-atom-command", () => ({ state.requests.push({ action, ...request }); const scope = action === "removeWorktree" ? AuthSourceControlWriteScope : AuthOrchestrationOperateScope; - // A remembered offline grant lets the command queue settle and snooze locally. + // Without a loaded session the command itself queues settle and snooze. if ( !state.scopes.get(request.environmentId)?.has(scope) && - !state.offlineTriage.has(request.environmentId) + !state.sessionNotLoaded.has(request.environmentId) ) { return AsyncResult.failure(Cause.fail(new Error("Server denied the request"))); } @@ -107,9 +110,6 @@ vi.mock("../state/server", async (importOriginal) => { }; }); vi.mock("../state/threads", () => ({ - readThreadTriageScope: (environmentId: string) => - state.scopes.get(environmentId)?.has(AuthOrchestrationOperateScope) === true || - state.offlineTriage.has(environmentId), threadEnvironment: Object.fromEntries( [ "archive", @@ -278,7 +278,7 @@ beforeEach(() => { latestTurn: null, }, ]; - state.offlineTriage = new Set(); + state.sessionNotLoaded = new Set(); state.requests = []; state.localEffects = []; state.confirm.mockReset().mockResolvedValue(true); @@ -320,9 +320,9 @@ describe("thread action permissions", () => { ); it.each(operations)( - "$name follows the remembered offline grant only for settle and snooze", + "$name runs before the session loads only for settle, snooze and their undo", async ({ name, run }) => { - state.offlineTriage.add(secondary); + state.sessionNotLoaded.add(secondary); const result = await run(useThreadActions(), target); const triage = ["settle", "unsettle", "snooze", "unsnooze"].includes(name); expect(result._tag).toBe(triage ? "Success" : "Failure"); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index a224d5d584630..b0bc7a9441d42 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -24,13 +24,13 @@ import { useRouter } from "@tanstack/react-router"; import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; -import { environmentSession, readEnvironmentScope } from "../state/session"; +import { environmentSession, readEnvironmentScope, readThreadTriageScope } from "../state/session"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentServerConfigsAtom } from "../state/server"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { orchestrationEnvironment } from "../state/orchestration"; import { projectEnvironment } from "../state/projects"; -import { readThreadTriageScope, threadEnvironment } from "../state/threads"; +import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; diff --git a/apps/web/src/hooks/useThreadActions.undo.test.ts b/apps/web/src/hooks/useThreadActions.undo.test.ts index 7df29423a7266..1889aaa1bca84 100644 --- a/apps/web/src/hooks/useThreadActions.undo.test.ts +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -281,10 +281,6 @@ describe("settle and snooze Undo", () => { const snoozedUntil = "2030-01-01T09:00:00.000Z"; threadShell.pinnedAt = "2026-01-01T00:00:00.000Z"; threadShell.snoozedUntil = snoozedUntil; - commands.settle.mockResolvedValue({ - _tag: "Success", - value: { sequence: 1, pendingSync: true }, - }); commands.unsettle.mockResolvedValue({ _tag: "Success", value: undone }); await useThreadActions().settleThread(target); expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Settled", count: 1 }); diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts index 2c2de0a107d36..370025e2f02d6 100644 --- a/apps/web/src/state/session.ts +++ b/apps/web/src/state/session.ts @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; import { + AuthOrchestrationOperateScope, type AuthEnvironmentScope, type AuthSessionState, type EnvironmentId, @@ -73,6 +74,34 @@ export function readEnvironmentScope( ); } +/** + * Settle and snooze can queue for an environment whose session has not loaded yet, such as one + * that was offline at launch. The server checks the grant when the change is sent. + */ +function sessionAllowsThreadTriage( + result: AsyncResult.AsyncResult, +): boolean { + return ( + !AsyncResult.isSuccess(result) || + sessionGrantsScope(result.value, AuthOrchestrationOperateScope) + ); +} + +export function useThreadTriageScope(environmentId: EnvironmentId | null): boolean { + return useAtomValue( + environmentId === null + ? EMPTY_SESSION_STATE_ATOM + : environmentSession.sessionStateAtom(environmentId), + sessionAllowsThreadTriage, + ); +} + +export function readThreadTriageScope(environmentId: EnvironmentId): boolean { + return sessionAllowsThreadTriage( + appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)), + ); +} + const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe( Atom.withLabel("web-prepared-connection:empty"), ); diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index 2b87f89756c13..1cb2749055806 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -12,19 +12,12 @@ import { createThreadEnvironmentAtoms, createThreadFamilyAtoms, } from "@t3tools/client-runtime/state/threads"; -import { - AuthOrchestrationOperateScope, - type EnvironmentId, - type OrchestrationV2ThreadShell, - type ThreadId, -} from "@t3tools/contracts"; +import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; -import { appAtomRegistry } from "../rpc/atomRegistry"; -import { environmentSession, readEnvironmentScope } from "./session"; import { serverEnvironment } from "./server"; import { environmentShell } from "./shell"; @@ -39,22 +32,8 @@ export const olderSettledThreads = createOlderSettledThreadAtoms( export const threadEnvironment = createThreadEnvironmentAtoms( connectionAtomRuntime, threadFamilies.snapshotAtom, - { - shellStatusAtom: environmentShell.statusAtom, - operateScopeAtom: environmentSession.operateScopeAtom, - serverConfigAtom: serverEnvironment.configValueAtom, - }, + environmentShell.statusAtom, ); -/** Settle, snooze and their undo can queue offline unless a loaded session denies it. */ -export function readThreadTriageScope(environmentId: EnvironmentId): boolean { - return ( - readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || - appAtomRegistry.get(threadEnvironment.offlineTriagePermittedAtom(environmentId)) - ); -} -export function readThreadPendingSync(environmentId: EnvironmentId, threadId: ThreadId): boolean { - return appAtomRegistry.get(threadEnvironment.pendingSyncAtom(environmentId)).has(threadId); -} const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( environmentThreads.stateAtom, diff --git a/apps/web/src/state/use-orchestration-command.ts b/apps/web/src/state/use-orchestration-command.ts index 7a5c956757fbc..7c37b83ae82f3 100644 --- a/apps/web/src/state/use-orchestration-command.ts +++ b/apps/web/src/state/use-orchestration-command.ts @@ -22,8 +22,8 @@ function hasLiveOperateGrant(environmentId: EnvironmentId): boolean { } /** - * `permitted` replaces the live operate check, so settle, snooze and their undo can pass the - * remembered offline grant. It runs on every call, like the default. + * `permitted` replaces the operate check, so settle, snooze and their undo can queue before the + * environment's session loads. It runs on every call, like the default. */ export function useOrchestrationCommand( command: AtomCommand, diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index b0762e97254b0..059e2d704e6c3 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -173,13 +173,11 @@ when the thread settles** enabled in Settings > Projects > Actions. You can also that action so T3 can stop its terminal and child processes. Commands started through an agent's own shell tools are managed by the provider. -You can settle or snooze a thread while its environment is offline, if the environment -runs a release with offline sync. The thread shows **Waiting to sync** on mobile or -**Pending** on web and desktop, and the change is sent when the environment -reconnects. If the thread changed there in the meantime, that environment's state -wins and your change is dropped, as it is when this connection cannot change threads there. -Undo a pending settle before snoozing the same -thread while offline. +You can settle or snooze a thread while its environment is offline. The thread shows +**Waiting to sync** on mobile or **Pending** on web and desktop, and the change is sent +when the environment reconnects. If the thread changed there in the meantime, that +environment's state wins and your change is dropped, as it is when this connection cannot +change threads there. A thread with a pending settle cannot be snoozed until it syncs. To reclaim disk space from settled work, turn on **Run in the thread's worktree when the thread settles** for one of the project's actions, or set `"runOnSettle": true` on a diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index ff865aab2765c..f813ece95dc45 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -32,7 +32,6 @@ import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2Pend import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; import { getInitialServerConfig, request } from "../rpc/client.ts"; @@ -91,10 +90,7 @@ export interface DeleteThreadInput extends ThreadCommandInput { } export type ArchiveThreadInput = ThreadCommandInput; export type UnarchiveThreadInput = ThreadCommandInput; -export interface SettleThreadInput extends ThreadCommandInput { - /** Replays an offline settle; the server rejects it if the thread changed after this. */ - readonly snapshotSequence?: number; -} +export type SettleThreadInput = ThreadCommandInput; export interface UnsettleThreadInput extends ThreadCommandInput { readonly reason: "user"; @@ -115,8 +111,6 @@ export interface ReorderActiveThreadInput extends ThreadCommandInput { export interface SnoozeThreadInput extends ThreadCommandInput { readonly snoozedUntil: string; - /** Replays an offline snooze; the server rejects it if the thread changed after this. */ - readonly snapshotSequence?: number; } export interface UnsnoozeThreadInput extends ThreadCommandInput { @@ -288,22 +282,6 @@ const dispatch = (command: OrchestrationV2Command) => const getProjection = (threadId: ThreadId) => request(ORCHESTRATION_V2_WS_METHODS.getThreadProjection, { threadId }); -export class ThreadVersionGuardUnsupportedError extends Schema.TaggedError()( - "ThreadVersionGuardUnsupportedError", - { message: Schema.String }, -) {} - -// Older servers drop snapshotSequence, so a replay there could overwrite newer changes. -export const requireThreadVersionGuard = Effect.fn("EnvironmentCommands.requireThreadVersionGuard")( - function* () { - const config = yield* getInitialServerConfig(); - if (config.environment.capabilities.threadTriageVersionGuard === true) return; - return yield* new ThreadVersionGuardUnsupportedError({ - message: "This environment's server is too old to sync offline changes. Update the server.", - }); - }, -); - const persistAttachments = Effect.fn("EnvironmentCommands.persistAttachments")(function* ( threadId: ThreadId, messageId: MessageId, @@ -516,16 +494,7 @@ export const unarchiveThread = Effect.fn("EnvironmentCommands.unarchiveThread")( export const settleThread = Effect.fn("EnvironmentCommands.settleThread")(function* ( input: SettleThreadInput, ) { - if (input.snapshotSequence === undefined) { - return yield* simpleThreadCommand("thread.settle", input); - } - yield* requireThreadVersionGuard(); - return yield* dispatch({ - type: "thread.settle", - commandId: yield* allocateCommandId(input), - threadId: input.threadId, - snapshotSequence: input.snapshotSequence, - }); + return yield* simpleThreadCommand("thread.settle", input); }); export const pinThread = Effect.fn("EnvironmentCommands.pinThread")(function* ( @@ -597,13 +566,11 @@ export const unsettleThread = Effect.fn("EnvironmentCommands.unsettleThread")(fu export const snoozeThread = Effect.fn("EnvironmentCommands.snoozeThread")(function* ( input: SnoozeThreadInput, ) { - if (input.snapshotSequence !== undefined) yield* requireThreadVersionGuard(); return yield* dispatch({ type: "thread.snooze", commandId: yield* allocateCommandId(input), threadId: input.threadId, snoozedUntil: input.snoozedUntil, - ...(input.snapshotSequence === undefined ? {} : { snapshotSequence: input.snapshotSequence }), }); }); diff --git a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts index 276db56065c31..589bdc75433f5 100644 --- a/packages/client-runtime/src/platform/threadTriageOutboxStore.ts +++ b/packages/client-runtime/src/platform/threadTriageOutboxStore.ts @@ -1,4 +1,4 @@ -import { CommandId, EnvironmentId, NonNegativeInt, ThreadId } from "@t3tools/contracts"; +import { CommandId, EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -14,22 +14,17 @@ export const PendingThreadTriage = Schema.Struct({ Schema.Struct({ type: Schema.Literal("snooze"), snoozedUntil: Schema.String }), ]), queuedAt: Schema.DateTimeUtcFromString, - /** Shell sequence the action was taken against; the server rejects it after newer changes. */ - snapshotSequence: NonNegativeInt, + /** The thread as the user saw it; if it shows otherwise on reconnect, the change is dropped. */ + seen: Schema.String, /** Set before the first send and never cleared: the server may have it, so only it can undo. */ attempted: Schema.optionalKey(Schema.Literal(true)), }); export type PendingThreadTriage = typeof PendingThreadTriage.Type; +export type PendingThreadTriages = ReadonlyArray; -const ThreadTriageOutboxDocument = Schema.Struct({ - entries: Schema.Array(PendingThreadTriage), -}); -export type ThreadTriageOutboxDocument = typeof ThreadTriageOutboxDocument.Type; - -const EMPTY_DOCUMENT: ThreadTriageOutboxDocument = { entries: [] }; -const StoredDocument = Schema.fromJsonString(ThreadTriageOutboxDocument); -const decodeStored = Schema.decodeUnknownExit(StoredDocument); -const encodeStored = Schema.encodeSync(StoredDocument); +const Stored = Schema.fromJsonString(Schema.Struct({ entries: Schema.Array(PendingThreadTriage) })); +const decodeStored = Schema.decodeUnknownExit(Stored); +const encodeStored = Schema.encodeSync(Stored); export class ThreadTriageOutboxStorageError extends Schema.TaggedError()( "ThreadTriageOutboxStorageError", @@ -40,191 +35,65 @@ export class ThreadTriageOutboxStorageError extends Schema.TaggedError()( - "ThreadTriagePendingSettleError", - {}, -) { - override get message(): string { - return "Undo the pending settle before snoozing while offline."; - } -} - -export type ThreadTriageKey = Pick; - -export const sameThreadTriage = (a: ThreadTriageKey, b: ThreadTriageKey) => - a.environmentId === b.environmentId && a.threadId === b.threadId; - /** * Settle and snooze taken while an environment is offline, kept apart from the environment * cache so clearing the cache does not drop them. Removing the environment does. */ export interface ThreadTriageOutboxStoreShape { - readonly load: Effect.Effect; - /** Replaces any entry for the same thread, keeping its `attempted` mark. */ - readonly put: ( - entry: PendingThreadTriage, - ) => Effect.Effect; - /** Only removes the entry if it still has this command id. */ - readonly remove: ( - entry: ThreadTriageKey & Pick, - ) => Effect.Effect; - /** Marks the entry as sent; false when it was cancelled or replaced since it was read. */ - readonly markAttempted: ( - entry: ThreadTriageKey & Pick, - ) => Effect.Effect; - /** - * Removes the entry only if it still has this command id and was never sent. Otherwise it - * reports whether the entry was sent or is gone, and leaves storage as it is. - */ - readonly cancel: ( - entry: ThreadTriageKey & Pick, - ) => Effect.Effect<"cancelled" | "attempted" | "gone", ThreadTriageOutboxStorageError>; + /** Stores `change(stored)` in one step and returns it. Returning `stored` itself only reads. */ + readonly update: ( + change: (stored: PendingThreadTriages) => PendingThreadTriages, + ) => Effect.Effect; readonly clearEnvironment: ( environmentId: EnvironmentId, ) => Effect.Effect; - /** - * Lets in-memory copies drop a removed environment's entries; returns the unsubscribe. - * Listeners run inside the store's lock, before any later write. - */ - readonly subscribeCleared: (listener: (environmentId: EnvironmentId) => void) => () => void; } -export class ThreadTriageOutboxStore extends Context.Reference( - "@t3tools/client-runtime/state/ThreadTriageOutboxStore", - { - defaultValue: () => { - let value: string | null = null; - return makeThreadTriageOutboxStore({ - read: Effect.sync(() => value), - modify: (next) => - Effect.try({ - try: () => { - value = next(value) ?? value; - }, - catch: (cause) => new ThreadTriageOutboxStorageError({ cause }), - }), - }); - }, - }, -) {} - -export function makeThreadTriageOutboxStore(storage: { - readonly read: Effect.Effect; - /** - * Replaces the stored value with `next(stored)`, or keeps it when that is null, in one step - * that other processes sharing the storage, such as tabs, cannot interleave. - */ - readonly modify: ( +/** + * `modify` replaces the stored text with `next(stored)`, or keeps it when that is null. Text that + * no longer decodes reads as empty. + */ +export function makeThreadTriageOutboxStore( + modify: ( next: (stored: string | null) => string | null, - ) => Effect.Effect; -}): ThreadTriageOutboxStoreShape { + ) => Effect.Effect, +): ThreadTriageOutboxStoreShape { + // Platforms whose `modify` reads and writes in separate steps rely on this to not interleave. const lock = Semaphore.makeUnsafe(1); - const clearedListeners = new Set<(environmentId: EnvironmentId) => void>(); - const parse = (raw: string | null) => - raw === null ? Exit.succeed(EMPTY_DOCUMENT) : decodeStored(raw); - const discardUnreadable = Effect.logWarning("Discarding unreadable offline thread changes.", { - reason: "decode", - }); - const load = storage.read.pipe( - Effect.flatMap((raw) => - Exit.match(parse(raw), { - onSuccess: Effect.succeed, - onFailure: () => discardUnreadable.pipe(Effect.as(EMPTY_DOCUMENT)), - }), - ), - ); - /** A null transform skips the write. `after` still runs inside the lock. */ - const update = ( - transform: (document: ThreadTriageOutboxDocument) => ThreadTriageOutboxDocument | null, - after: Effect.Effect = Effect.void, - ) => + const update: ThreadTriageOutboxStoreShape["update"] = (change) => lock.withPermits(1)( Effect.suspend(() => { - let parsed = parse(null); - return storage - .modify((raw) => { - parsed = parse(raw); - const next = transform(Exit.isSuccess(parsed) ? parsed.value : EMPTY_DOCUMENT); - return next === null ? null : encodeStored(next); - }) - .pipe(Effect.tap(() => (Exit.isFailure(parsed) ? discardUnreadable : Effect.void))); - }).pipe(Effect.ensuring(after)), + let result: PendingThreadTriages = []; + return modify((raw) => { + const decoded = raw === null ? undefined : decodeStored(raw); + const stored = + decoded !== undefined && Exit.isSuccess(decoded) ? decoded.value.entries : []; + result = change(stored); + return result === stored ? null : encodeStored({ entries: result }); + }).pipe(Effect.map(() => result)); + }), ); - const updateEntries = ( - transform: (entries: ReadonlyArray) => ReadonlyArray, - ) => update((document) => ({ entries: transform(document.entries) })); return { - load: lock.withPermits(1)(load), - put: (entry) => - Effect.suspend(() => { - let blocked = false; - return update((document) => { - const replaced = document.entries.find((current) => sameThreadTriage(current, entry)); - if (replaced?.action.type === "settle" && entry.action.type === "snooze") { - blocked = true; - return null; - } - return { - entries: [ - ...document.entries.filter((current) => current !== replaced), - // The replaced change may have reached the server, so the server must undo this one. - replaced?.attempted === true ? { ...entry, attempted: true as const } : entry, - ], - }; - }).pipe( - Effect.flatMap(() => - blocked ? Effect.fail(new ThreadTriagePendingSettleError()) : Effect.void, - ), - ); - }), - remove: (entry) => - updateEntries((entries) => - entries.filter( - (current) => !sameThreadTriage(current, entry) || current.commandId !== entry.commandId, - ), - ), - markAttempted: (entry) => - Effect.suspend(() => { - let marked = false; - return update((document) => { - const stored = document.entries.find((current) => sameThreadTriage(current, entry)); - if (stored?.commandId !== entry.commandId) return null; - marked = true; - if (stored.attempted === true) return null; - return { - entries: document.entries.map((current) => - current === stored ? { ...stored, attempted: true as const } : current, - ), - }; - }).pipe(Effect.map(() => marked)); - }), - cancel: (entry) => - Effect.suspend(() => { - let outcome: "cancelled" | "attempted" | "gone" = "gone"; - return update((document) => { - const stored = document.entries.find((current) => sameThreadTriage(current, entry)); - if (stored?.commandId !== entry.commandId) return null; - if (stored.attempted === true) { - outcome = "attempted"; - return null; - } - outcome = "cancelled"; - return { entries: document.entries.filter((current) => current !== stored) }; - }).pipe(Effect.map(() => outcome)); - }), + update, clearEnvironment: (environmentId) => - update( - (document) => ({ - entries: document.entries.filter((entry) => entry.environmentId !== environmentId), - }), - // The environment is gone even if storage failed, so memory must not replay it. + update((stored) => + stored.some((entry) => entry.environmentId === environmentId) + ? stored.filter((entry) => entry.environmentId !== environmentId) + : stored, + ).pipe(Effect.asVoid), + }; +} + +export class ThreadTriageOutboxStore extends Context.Reference( + "@t3tools/client-runtime/state/ThreadTriageOutboxStore", + { + defaultValue: () => { + let value: string | null = null; + return makeThreadTriageOutboxStore((next) => Effect.sync(() => { - for (const listener of clearedListeners) listener(environmentId); + value = next(value) ?? value; }), - ), - subscribeCleared: (listener) => { - clearedListeners.add(listener); - return () => clearedListeners.delete(listener); + ); }, - }; -} + }, +) {} diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 9648dcf5f0a41..9312ae048e6e0 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -1,11 +1,5 @@ import { pipe } from "effect/Function"; -import { - AuthOrchestrationOperateScope, - type AuthSessionState, - type EnvironmentId, - type ServerConfig, - sessionGrantsScope, -} from "@t3tools/contracts"; +import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -223,16 +217,6 @@ function makeEnvironmentSessionAtoms( ).pipe(Atom.withLabel(`environment-session-state-value:${environmentId}`)), ); - /** Null until a session loads, so offline callers can tell unknown from denied. */ - const operateScopeAtom = Atom.family((environmentId: EnvironmentId) => - Atom.make((get): boolean | null => { - const result = get(sessionStateAtom(environmentId)); - return AsyncResult.isSuccess(result) - ? sessionGrantsScope(result.value, AuthOrchestrationOperateScope) - : null; - }).pipe(Atom.withLabel(`environment-session-operate-scope:${environmentId}`)), - ); - return { initialConfigAtom, initialConfigValueAtom, @@ -240,7 +224,6 @@ function makeEnvironmentSessionAtoms( preparedConnectionValueAtom, sessionStateAtom, sessionStateValueAtom, - operateScopeAtom, }; } diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 020c30296eaf1..3ec8dc78cb07f 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -11,7 +11,6 @@ import { ThreadId, type OrchestrationV2Command, type OrchestrationV2ShellSnapshot, - type ServerConfig, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as SecureRandom from "effect/crypto/SecureRandom"; @@ -31,17 +30,15 @@ import { RpcClientError } from "effect/rpc"; import * as EnvironmentRegistry from "../connection/registry.ts"; import { BearerConnectionTarget } from "../connection/model.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; -import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; import type { RpcSession } from "../rpc/session.ts"; -import type { EnvironmentShellStatus } from "./shell.ts"; -import { createThreadEnvironmentAtoms } from "./threadCommands.ts"; -import * as ThreadUndo from "./threadUndo.ts"; import { makeThreadTriageOutboxStore, ThreadTriageOutboxStorageError, ThreadTriageOutboxStore, - type ThreadTriageOutboxStoreShape, } from "../platform/threadTriageOutboxStore.ts"; +import type { EnvironmentShellStatus } from "./shell.ts"; +import { createThreadEnvironmentAtoms } from "./threadCommands.ts"; +import * as ThreadUndo from "./threadUndo.ts"; import * as ThreadHistoryController from "./threadHistoryController.ts"; const ENVIRONMENT_ID = EnvironmentId.make("remote"); @@ -105,30 +102,15 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio readonly loadEarlier?: () => Effect.Effect; readonly reconcilePendingWork?: (threadId: ThreadId) => Effect.Effect; readonly connected?: boolean; - readonly storage?: { - value: string | null; - failWrites?: boolean; - failReads?: boolean; - writes?: Queue.Queue; - reads?: Queue.Queue; - }; - readonly versionGuard?: boolean; - /** The guard capability in the cached config, which may differ from the live server's. */ - readonly cachedVersionGuard?: boolean; - readonly wrapStore?: (store: ThreadTriageOutboxStoreShape) => ThreadTriageOutboxStoreShape; + /** Shared between harnesses to stand in for a restart. */ + readonly storage?: { value: string | null; failWrites?: boolean }; }) { const requests = yield* Queue.unbounded<{ command: OrchestrationV2Command; reply: Deferred.Deferred<{ sequence: number }, Error>; }>(); const reconciled = yield* Queue.unbounded(); - const configReads = yield* Queue.unbounded(); - const liveSession = { - initialConfig: Queue.offer(configReads, undefined).pipe( - Effect.as({ - environment: { capabilities: { threadTriageVersionGuard: options?.versionGuard ?? true } }, - }), - ), + const liveSession = Option.some({ client: { [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) => Effect.gen(function* () { @@ -137,9 +119,9 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio return yield* Deferred.await(reply); }), }, - } as unknown as RpcSession; + } as unknown as RpcSession); const session = yield* SubscriptionRef.make( - options?.connected === false ? Option.none() : Option.some(liveSession), + options?.connected === false ? Option.none() : liveSession, ); const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: new BearerConnectionTarget({ @@ -150,30 +132,17 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio session, } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]); const storage = options?.storage ?? { value: null }; - const store = makeThreadTriageOutboxStore({ - read: Effect.suspend(() => { - if (storage.reads) Queue.offerUnsafe(storage.reads, undefined); - return storage.failReads - ? Effect.fail(new ThreadTriageOutboxStorageError({ cause: "unreadable" })) - : Effect.succeed(storage.value); + const store = makeThreadTriageOutboxStore((next) => + Effect.suspend(() => { + const value = next(storage.value); + if (value === null) return Effect.void; + if (storage.failWrites) { + return Effect.fail(new ThreadTriageOutboxStorageError({ cause: "disk full" })); + } + storage.value = value; + return Effect.void; }), - modify: (next) => - Effect.suspend(() => { - if (storage.reads) Queue.offerUnsafe(storage.reads, undefined); - if (storage.failReads) { - return Effect.fail(new ThreadTriageOutboxStorageError({ cause: "unreadable" })); - } - const value = next(storage.value); - if (value === null) return Effect.void; - if (storage.failWrites) { - return Effect.fail(new ThreadTriageOutboxStorageError({ cause: "disk full" })); - } - return Effect.sync(() => { - storage.value = value; - if (storage.writes) Queue.offerUnsafe(storage.writes, value); - }); - }), - }); + ); const runtime = Atom.runtime( Layer.mergeAll( Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, { @@ -185,7 +154,7 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio // Distinct bytes per call, so each queued change gets its own command id. SecureRandom.make((size) => new Uint8Array(size).fill(++randomCalls % 256)), ), - Layer.succeed(ThreadTriageOutboxStore, options?.wrapStore?.(store) ?? store), + Layer.succeed(ThreadTriageOutboxStore, store), Layer.succeed( ThreadHistoryController.ThreadHistoryController, ThreadHistoryController.ThreadHistoryController.of({ @@ -202,23 +171,15 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio const statusAtom = Atom.family((_environmentId: EnvironmentId) => Atom.make(options?.connected === false ? "cached" : "live"), ); - const serverConfigAtom = Atom.family((_environmentId: EnvironmentId) => - Atom.make({ - environment: { - capabilities: { threadTriageVersionGuard: options?.cachedVersionGuard ?? true }, - }, - } as unknown as ServerConfig), - ); - const commands = createThreadEnvironmentAtoms(runtime, snapshotAtom, { - shellStatusAtom: statusAtom, - serverConfigAtom, - }); + const commands = createThreadEnvironmentAtoms(runtime, snapshotAtom, statusAtom); const registry = AtomRegistry.make(); yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); const visibleAtom = commands.snapshotAtom(ENVIRONMENT_ID); registry.mount(visibleAtom); + const pendingSyncAtom = commands.pendingSyncAtom(ENVIRONMENT_ID); + registry.mount(pendingSyncAtom); const reconnect = (status: EnvironmentShellStatus = "live") => - SubscriptionRef.set(session, Option.some(liveSession)).pipe( + SubscriptionRef.set(session, liveSession).pipe( Effect.andThen(Effect.sync(() => registry.set(statusAtom(ENVIRONMENT_ID), status))), ); const disconnect = SubscriptionRef.set(session, Option.none()).pipe( @@ -230,11 +191,11 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio snapshotAtom, statusAtom, visibleAtom, + pendingSyncAtom, requests, reconciled, storage, store, - configReads, reconnect, disconnect, }; @@ -402,8 +363,7 @@ describe("remote thread lifecycle commands", () => { const request = yield* Queue.take(h.requests); expect(h.registry.get(source)).toBe(initial); yield* Deferred.fail(request.reply, new Error("Remote rejected the action")); - const settled: Promise<{ readonly _tag: string }> = result; - expect((yield* Effect.promise(() => settled))._tag).toBe("Failure"); + expect((yield* Effect.promise(() => result))._tag).toBe("Failure"); expect(h.registry.get(h.visibleAtom)).toBe(initial); }), ); @@ -664,11 +624,19 @@ describe("settle and snooze while the environment is offline", () => { environmentId: ENVIRONMENT_ID, input: { threadId: THREAD_ID, snoozedUntil: "2099-01-01T00:00:00.000Z" }, }; + const undo = { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, reason: "user" as const }, + }; const stored = (storage: { value: string | null }) => storage.value === null ? [] : (JSON.parse(storage.value) as { entries: Array> }).entries; - + const changedThread = (change: Partial) => ({ + ...SNAPSHOT, + snapshotSequence: 5, + threads: [{ ...SNAPSHOT.threads[0]!, ...change }], + }); /** The request may have reached the server before the socket closed. */ const lostReply = () => new RpcClientError.RpcClientError({ @@ -677,186 +645,65 @@ describe("settle and snooze while the environment is offline", () => { cause: new Error("socket closed"), }), }); + const queued = Effect.fn("queued")(function* ( + action: "settle" | "snooze" = "settle", + storage?: { value: string | null }, + ) { + const h = yield* makeHarness({ connected: false, ...(storage ? { storage } : {}) }); + const result = yield* Effect.promise(() => h.commands[action].run(h.registry, snooze)); + expect(result._tag).toBe("Success"); + return h; + }); + type Harness = Effect.Success>; + const nothingPending = (h: Harness) => + waitFor(h.registry, h.pendingSyncAtom, (threads) => threads.size === 0); + /** Commands on one thread go out in order, so this one arrives first only if no other was sent. */ + const expectNothingSent = Effect.fn("expectNothingSent")(function* (h: Harness) { + const pin = h.commands.pin.run(h.registry, settle); + const request = yield* Queue.take(h.requests); + expect(request.command.type).toBe("thread.pin"); + yield* Deferred.succeed(request.reply, { sequence: 1 }); + yield* Effect.promise(() => pin); + }); - // An older server cannot check the sequence, so the connected command goes out unguarded. - it.effect.each([ - { action: "settle", guarded: true }, - { action: "snooze", guarded: true }, - { action: "settle", guarded: false }, - { action: "snooze", guarded: false }, - ] as const)( - "sends a $action taken before shell synchronization (version guard: $guarded)", - ({ action, guarded }) => - Effect.gen(function* () { - const h = yield* makeHarness({ versionGuard: guarded, cachedVersionGuard: guarded }); - h.registry.set(h.statusAtom(ENVIRONMENT_ID), "synchronizing"); - const result = h.commands[action].run(h.registry, snooze); - const request = yield* Queue.take(h.requests); - expect(Reflect.get(request.command, "snapshotSequence")).toBe(guarded ? 1 : undefined); - yield* Deferred.succeed(request.reply, { sequence: 2 }); - expect((yield* Effect.promise(() => result))._tag).toBe("Success"); - }), - ); - - it.effect("discovers a change another tab queued after this tab loaded", () => - Effect.gen(function* () { - const storage = { value: null as string | null }; - const loaded = yield* Deferred.make(); - const second = yield* makeHarness({ - connected: false, - storage, - wrapStore: (store) => ({ - ...store, - load: store.load.pipe(Effect.tap(() => Deferred.succeed(loaded, undefined))), - }), - }); - yield* Deferred.await(loaded); - const first = yield* makeHarness({ connected: false, storage }); - yield* Effect.promise(() => first.commands.settle.run(first.registry, settle)); - yield* second.reconnect(); - const replayed = yield* Queue.take(second.requests); - expect(replayed.command.type).toBe("thread.settle"); - }), - ); - - it.effect("clears the pending entry after Undo starts during a replay with a lost reply", () => - Effect.gen(function* () { - const h = yield* makeHarness({ connected: false }); - const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); - h.registry.mount(pending); - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - yield* h.reconnect(); - const replay = yield* Queue.take(h.requests); - const undo = h.commands.unsettle.run(h.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, reason: "user" }, - }); - yield* Deferred.fail(replay.reply, lostReply()); - const request = yield* Queue.take(h.requests); - yield* Deferred.succeed(request.reply, { sequence: 3 }); - expect((yield* Effect.promise(() => undo))._tag).toBe("Success"); - expect(h.registry.get(pending).size).toBe(0); - expect(stored(h.storage)).toEqual([]); - }), - ); - - it.effect("does not accept an invisible offline settle of a cached running thread", () => - Effect.gen(function* () { - const h = yield* makeHarness({ connected: false }); - h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), { - ...SNAPSHOT, - threads: [{ ...SNAPSHOT.threads[0]!, status: "running" }], - }); - const result = yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - expect(result._tag).toBe("Failure"); - expect(stored(h.storage)).toEqual([]); - }), - ); - - it.effect("keeps a pending settle when snooze would discard it offline", () => - Effect.gen(function* () { - const h = yield* makeHarness({ connected: false }); - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - const queued = h.storage.value; - const result = yield* Effect.promise(() => h.commands.snooze.run(h.registry, snooze)); - expect(result._tag === "Failure" ? String(Cause.squash(result.cause)) : "Success").toContain( - "Undo the pending settle before snoozing while offline.", - ); - expect(h.storage.value).toBe(queued); - }), - ); - - it.effect("applies a settle locally and replays it with the cached version on reconnect", () => + it.effect("shows a settle at once and sends it when the environment is back", () => Effect.gen(function* () { - const h = yield* makeHarness({ connected: false }); - const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); - h.registry.mount(pending); - const result = yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - expect(result._tag).toBe("Success"); + const h = yield* queued(); expect(h.registry.get(h.visibleAtom)?.threads[0]?.settledOverride).toBe("settled"); - expect(h.registry.get(pending).has(THREAD_ID)).toBe(true); + expect(h.registry.get(h.pendingSyncAtom).has(THREAD_ID)).toBe(true); const [entry] = stored(h.storage); - expect(entry).toMatchObject({ - environmentId: ENVIRONMENT_ID, - threadId: THREAD_ID, - action: { type: "settle" }, - snapshotSequence: 1, - }); + expect(entry).toMatchObject({ threadId: THREAD_ID, action: { type: "settle" } }); - yield* h.reconnect("synchronizing"); - expect(yield* Queue.size(h.requests)).toBe(0); - h.registry.set(h.statusAtom(ENVIRONMENT_ID), "live"); + yield* h.reconnect(); const request = yield* Queue.take(h.requests); expect(request.command).toMatchObject({ type: "thread.settle", commandId: entry!.commandId, threadId: THREAD_ID, - snapshotSequence: 1, }); + // Recorded before the send, so a restart or Undo from here asks the server. + expect(stored(h.storage)[0]!.attempted).toBe(true); yield* Deferred.succeed(request.reply, { sequence: 2 }); - yield* waitFor(h.registry, pending, (threads) => threads.size === 0); + yield* nothingPending(h); expect(stored(h.storage)).toEqual([]); - // Stays settled until the shell catches up with the accepted command. - expect(h.registry.get(h.visibleAtom)?.threads[0]?.settledOverride).toBe("settled"); - const confirmed = { - ...SNAPSHOT, - snapshotSequence: 2, - threads: [{ ...SNAPSHOT.threads[0]!, settledOverride: "settled" as const, settledAt: NOW }], - }; - h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), confirmed); - expect(h.registry.get(h.visibleAtom)).toBe(confirmed); }), ); - it.effect.each(["settle", "snooze"] as const)( - "replays a %s when reconnect finishes during the queue write", - (action) => - Effect.gen(function* () { - const putStarted = yield* Deferred.make(); - const putReleased = yield* Deferred.make(); - const h = yield* makeHarness({ - connected: false, - wrapStore: (store) => ({ - ...store, - put: (entry) => - Deferred.succeed(putStarted, undefined).pipe( - Effect.andThen(Deferred.await(putReleased)), - Effect.andThen(store.put(entry)), - ), - }), - }); - const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); - h.registry.mount(pending); - const queued = h.commands[action].run(h.registry, snooze); - yield* Deferred.await(putStarted); - yield* h.reconnect(); - yield* Deferred.succeed(putReleased, undefined); - expect((yield* Effect.promise(() => queued))._tag).toBe("Success"); - const request = yield* Queue.take(h.requests); - expect(request.command.type).toBe(`thread.${action}`); - expect(request.command.commandId).toBe(stored(h.storage)[0]!.commandId); - yield* Deferred.succeed(request.reply, { sequence: 2 }); - yield* waitFor(h.registry, pending, (threads) => threads.size === 0); - }), - ); - - it.effect("restores a queued snooze after a restart and replays it once", () => + it.effect("sends a queued snooze after a restart, under the command id it was stored with", () => Effect.gen(function* () { const storage = { value: null as string | null }; - const first = yield* makeHarness({ connected: false, storage }); - expect( - (yield* Effect.promise(() => first.commands.snooze.run(first.registry, snooze)))._tag, - ).toBe("Success"); + yield* queued("snooze", storage); const [entry] = stored(storage); const restarted = yield* makeHarness({ connected: false, storage }); - const pending = restarted.commands.pendingSyncAtom(ENVIRONMENT_ID); - restarted.registry.mount(pending); - yield* waitFor(restarted.registry, pending, (threads) => threads.has(THREAD_ID)); + yield* waitFor( + restarted.registry, + restarted.pendingSyncAtom, + (threads) => threads.size === 1, + ); expect(restarted.registry.get(restarted.visibleAtom)?.threads[0]?.snoozedUntil).toEqual( FUTURE, ); - yield* restarted.reconnect(); const request = yield* Queue.take(restarted.requests); expect(request.command).toMatchObject({ @@ -865,435 +712,197 @@ describe("settle and snooze while the environment is offline", () => { snoozedUntil: "2099-01-01T00:00:00.000Z", }); yield* Deferred.succeed(request.reply, { sequence: 2 }); - yield* waitFor(restarted.registry, pending, (threads) => threads.size === 0); - expect(yield* Queue.size(restarted.requests)).toBe(0); + yield* nothingPending(restarted); + yield* expectNothingSent(restarted); }), ); - it.effect("shows the server state when the server rejects the replay as changed", () => - Effect.gen(function* () { - const h = yield* makeHarness({ connected: false }); - const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); - h.registry.mount(pending); - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - const changed = { - ...SNAPSHOT, - snapshotSequence: 5, - threads: [ - { ...SNAPSHOT.threads[0]!, title: "Changed on the Mac", status: "running" as const }, - ], - }; + it.effect.each([ + ["produced new output", { itemCount: 1 }], + ["started a run", { latestRunId: RunId.make("later-run") }], + ["was pinned on another device", { pinnedAt: NOW }], + ["was settled on another device", { settledOverride: "settled" as const, settledAt: NOW }], + ["is asking for approval", { pendingRuntimeRequest: APPROVAL }], + ] as const)("keeps the server's state for a thread that %s meanwhile", ([, change]) => + Effect.gen(function* () { + const h = yield* queued(); + // Judged against the synchronized shell: sending any earlier would leave it pending here. + yield* h.reconnect("synchronizing"); + const changed = changedThread(change); h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), changed); - yield* h.reconnect(); - const request = yield* Queue.take(h.requests); - yield* Deferred.fail(request.reply, new Error("Thread changed after the client's version.")); - yield* waitFor(h.registry, pending, (threads) => threads.size === 0); + h.registry.set(h.statusAtom(ENVIRONMENT_ID), "live"); + yield* nothingPending(h); + yield* expectNothingSent(h); expect(stored(h.storage)).toEqual([]); expect(h.registry.get(h.visibleAtom)).toBe(changed); }), ); - it.effect("keeps the queued action when the connection drops again and retries it", () => + it.effect("sends a queued change again when the first send got no answer", () => Effect.gen(function* () { - const h = yield* makeHarness({ connected: false }); - const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); - h.registry.mount(pending); - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - const [entry] = stored(h.storage); - // The shell reports live from a stale stream while the session is already gone. - h.registry.set(h.statusAtom(ENVIRONMENT_ID), "live"); + const h = yield* queued(); + yield* h.reconnect(); + const first = yield* Queue.take(h.requests); yield* h.disconnect; - expect(h.registry.get(pending).has(THREAD_ID)).toBe(true); + yield* Deferred.fail(first.reply, lostReply()); + expect(h.registry.get(h.pendingSyncAtom).has(THREAD_ID)).toBe(true); + yield* h.reconnect(); + const second = yield* Queue.take(h.requests); + expect(second.command.commandId).toBe(first.command.commandId); + yield* Deferred.succeed(second.reply, { sequence: 2 }); + yield* nothingPending(h); + }), + ); + + it.effect("drops a queued change the server refuses, and shows the server's state", () => + Effect.gen(function* () { + const h = yield* queued(); yield* h.reconnect(); const request = yield* Queue.take(h.requests); - expect(request.command.commandId).toBe(entry!.commandId); - yield* Deferred.succeed(request.reply, { sequence: 2 }); - yield* waitFor(h.registry, pending, (threads) => threads.size === 0); + yield* Deferred.fail(request.reply, new Error("This session cannot change threads.")); + yield* nothingPending(h); + expect(stored(h.storage)).toEqual([]); + expect(h.registry.get(h.visibleAtom)).toBe(SNAPSHOT); }), ); it.effect.each([ ["settle", "unsettle"], ["snooze", "unsnooze"], - ] as const)("undoing a queued %s cancels it without contacting the server", ([action, undo]) => + ] as const)("undoing a queued %s cancels it without contacting the server", ([action, undoing]) => Effect.gen(function* () { - const h = yield* makeHarness({ connected: false }); - const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); - h.registry.mount(pending); - yield* Effect.promise(() => h.commands[action].run(h.registry, snooze)); - const result = yield* Effect.promise(() => - h.commands[undo].run(h.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, reason: "user" }, - }), - ); + const h = yield* queued(action); + const result = yield* Effect.promise(() => h.commands[undoing].run(h.registry, undo)); expect(result._tag === "Success" && ThreadUndo.cancelledPendingSync(result.value)).toBe(true); - expect(h.registry.get(pending).size).toBe(0); + expect(h.registry.get(h.pendingSyncAtom).size).toBe(0); expect(stored(h.storage)).toEqual([]); expect(h.registry.get(h.visibleAtom)).toBe(SNAPSHOT); yield* h.reconnect(); - expect(yield* Queue.size(h.requests)).toBe(0); - }), - ); - - it.effect("drops a queued snooze that expired before the reconnect", () => - Effect.gen(function* () { - const storage = { value: null as string | null }; - const h = yield* makeHarness({ connected: false, storage }); - const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); - h.registry.mount(pending); - yield* Effect.promise(() => h.commands.snooze.run(h.registry, snooze)); - storage.value = storage.value!.replace( - "2099-01-01T00:00:00.000Z", - "2000-01-01T00:00:00.000Z", - ); - const restarted = yield* makeHarness({ connected: false, storage }); - const restartedPending = restarted.commands.pendingSyncAtom(ENVIRONMENT_ID); - restarted.registry.mount(restartedPending); - yield* waitFor(restarted.registry, restartedPending, (threads) => threads.size === 1); - yield* restarted.reconnect(); - yield* waitFor(restarted.registry, restartedPending, (threads) => threads.size === 0); - expect(yield* Queue.size(restarted.requests)).toBe(0); - expect(stored(storage)).toEqual([]); - }), - ); - - it.effect("keeps the not-connected failure when the change cannot be saved", () => - Effect.gen(function* () { - const h = yield* makeHarness({ - connected: false, - storage: { value: null, failWrites: true }, - }); - const result = yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - expect(result._tag).toBe("Failure"); - expect(String(result._tag === "Failure" ? Cause.squash(result.cause) : "")).toContain( - "Mac is not connected.", - ); - expect(h.registry.get(h.visibleAtom)).toBe(SNAPSHOT); - }), - ); - - it.effect("refuses to queue for a server that cannot check the snapshot", () => - Effect.gen(function* () { - const h = yield* makeHarness({ connected: false, cachedVersionGuard: false }); - const result = yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - expect(result._tag === "Failure" ? String(Cause.squash(result.cause)) : "Success").toContain( - "Mac is not connected. Update its server to settle or snooze while it is offline.", - ); - expect(h.registry.get(h.commands.pendingSyncAtom(ENVIRONMENT_ID)).size).toBe(0); - expect(h.registry.get(h.visibleAtom)).toBe(SNAPSHOT); + yield* expectNothingSent(h); }), ); - it.effect("keeps a queued change when the reconnected server cannot check it", () => + it.effect("after a lost reply, Undo asks the server and the change is not sent twice", () => Effect.gen(function* () { - const h = yield* makeHarness({ connected: false, versionGuard: false }); - const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); - h.registry.mount(pending); - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - yield* h.reconnect(); - yield* Queue.take(h.configReads); - // A second reconnect only replays after the first attempt finished. - yield* h.disconnect; + const h = yield* queued(); yield* h.reconnect(); - yield* Queue.take(h.configReads); - expect(yield* Queue.size(h.requests)).toBe(0); - expect(h.registry.get(pending).has(THREAD_ID)).toBe(true); - // Never sent, so it stays unmarked and Undo still cancels it on the device. - expect(stored(h.storage)[0]).not.toHaveProperty("attempted"); + const request = yield* Queue.take(h.requests); yield* h.disconnect; - const undo = yield* Effect.promise(() => - h.commands.unsettle.run(h.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, reason: "user" }, - }), - ); - expect(undo._tag === "Success" && ThreadUndo.cancelledPendingSync(undo.value)).toBe(true); - }), - ); + yield* Deferred.fail(request.reply, lostReply()); - it.effect("does not send a queued change cancelled while an earlier one replays", () => - Effect.gen(function* () { - const h = yield* makeHarness({ connected: false }); - const cancelledThreadId = ThreadId.make("cancelled-thread"); - const laterThreadId = ThreadId.make("later-thread"); - h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), { - ...SNAPSHOT, - threads: [ - ...SNAPSHOT.threads, - { ...SNAPSHOT.threads[0]!, id: cancelledThreadId }, - { ...SNAPSHOT.threads[0]!, id: laterThreadId }, - ], - }); - h.registry.mount(h.commands.pendingSyncAtom(ENVIRONMENT_ID)); - for (const threadId of [THREAD_ID, cancelledThreadId, laterThreadId]) { - yield* Effect.promise(() => - h.commands.settle.run(h.registry, { environmentId: ENVIRONMENT_ID, input: { threadId } }), - ); - } - yield* h.reconnect(); - const first = yield* Queue.take(h.requests); - expect(first.command).toMatchObject({ type: "thread.settle", threadId: THREAD_ID }); - const undo = yield* Effect.promise(() => - h.commands.unsettle.run(h.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: cancelledThreadId, reason: "user" }, - }), + // The server may have settled it, so cancelling on the device would lose the Undo. + const undone = yield* Effect.promise(() => h.commands.unsettle.run(h.registry, undo)); + expect(undone._tag).toBe("Failure"); + expect(h.registry.get(h.pendingSyncAtom).has(THREAD_ID)).toBe(true); + + // It had: the returning shell shows the thread settled, which answers the queued change. + h.registry.set( + h.snapshotAtom(ENVIRONMENT_ID), + changedThread({ settledOverride: "settled", settledAt: NOW }), ); - expect(undo._tag).toBe("Success"); - yield* Deferred.succeed(first.reply, { sequence: 2 }); - const next = yield* Queue.take(h.requests); - expect(next.command).toMatchObject({ type: "thread.settle", threadId: laterThreadId }); + yield* h.reconnect(); + yield* nothingPending(h); + yield* expectNothingSent(h); }), ); - it.effect("does not keep a change queued while its environment was being removed", () => + it.effect("a later action that reaches the server replaces the queued one", () => Effect.gen(function* () { - const putStarted = yield* Deferred.make(); - const putReleased = yield* Deferred.make(); - const h = yield* makeHarness({ - connected: false, - wrapStore: (store) => ({ - ...store, - put: (entry) => - Deferred.succeed(putStarted, undefined).pipe( - Effect.andThen(Deferred.await(putReleased)), - Effect.andThen(store.put(entry)), - ), - }), - }); - const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); - h.registry.mount(pending); - const queued = h.commands.settle.run(h.registry, settle); - yield* Deferred.await(putStarted); - yield* h.store.clearEnvironment(ENVIRONMENT_ID); - yield* Deferred.succeed(putReleased, undefined); - expect((yield* Effect.promise(() => queued))._tag).toBe("Failure"); - expect(h.registry.get(pending).size).toBe(0); + const h = yield* queued(); + yield* h.reconnect("synchronizing"); + const result = h.commands.snooze.run(h.registry, snooze); + const request = yield* Queue.take(h.requests); + expect(request.command.type).toBe("thread.snooze"); + yield* Deferred.succeed(request.reply, { sequence: 2 }); + expect((yield* Effect.promise(() => result))._tag).toBe("Success"); expect(stored(h.storage)).toEqual([]); + h.registry.set(h.statusAtom(ENVIRONMENT_ID), "live"); + yield* expectNothingSent(h); }), ); - it.effect("sends a replayed change once even when storage cannot drop it", () => + it.effect("drops a queued snooze that expired before the reconnect", () => Effect.gen(function* () { - const loads = yield* Queue.unbounded(); - const h = yield* makeHarness({ - connected: false, - wrapStore: (store) => ({ - ...store, - load: store.load.pipe(Effect.tap(() => Queue.offer(loads, undefined))), - remove: () => Effect.fail(new ThreadTriageOutboxStorageError({ cause: "disk full" })), - }), - }); - const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); - h.registry.mount(pending); - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - yield* h.reconnect(); - const replay = yield* Queue.take(h.requests); - yield* Queue.clear(loads); - yield* Deferred.succeed(replay.reply, { sequence: 2 }); - // Forgetting it locally starts another pass, which reads the entry storage kept. - yield* Queue.take(loads); - const next = yield* Effect.raceFirst( - Queue.take(h.requests).pipe(Effect.as("sent again" as const)), - h.disconnect.pipe( - Effect.andThen(h.reconnect()), - Effect.andThen(Queue.take(loads)), - Effect.as("quiet" as const), - ), + const storage = { value: null as string | null }; + yield* queued("snooze", storage); + storage.value = storage.value!.replace( + "2099-01-01T00:00:00.000Z", + "2000-01-01T00:00:00.000Z", ); - expect(next).toBe("quiet"); - expect(h.registry.get(pending).size).toBe(0); + const restarted = yield* makeHarness({ connected: false, storage }); + yield* waitFor( + restarted.registry, + restarted.pendingSyncAtom, + (threads) => threads.size === 1, + ); + yield* restarted.reconnect(); + yield* nothingPending(restarted); + yield* expectNothingSent(restarted); + expect(stored(storage)).toEqual([]); }), ); - it.effect("does not send a queued change it cannot record as sent", () => + it.effect("sends what storage holds when another tab replaced the change", () => Effect.gen(function* () { - const markFailed = yield* Deferred.make(); - let failMark = true; - const h = yield* makeHarness({ - connected: false, - wrapStore: (store) => ({ - ...store, - markAttempted: (entry) => - failMark - ? Deferred.succeed(markFailed, undefined).pipe( - Effect.andThen( - Effect.fail(new ThreadTriageOutboxStorageError({ cause: "disk full" })), - ), - ) - : store.markAttempted(entry), - }), + const storage = { value: null as string | null }; + const first = yield* queued("settle", storage); + const second = yield* makeHarness({ connected: false, storage }); + yield* waitFor(second.registry, second.pendingSyncAtom, (threads) => threads.size === 1); + yield* Effect.promise(() => second.commands.unsettle.run(second.registry, undo)); + yield* Effect.promise(() => second.commands.snooze.run(second.registry, snooze)); + + yield* first.reconnect(); + const request = yield* Queue.take(first.requests); + expect(request.command).toMatchObject({ + type: "thread.snooze", + commandId: stored(storage)[0]!.commandId, }); - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - yield* h.reconnect(); - yield* Deferred.await(markFailed); - failMark = false; - // A second reconnect only replays after the first attempt finished. - yield* h.disconnect; - yield* h.reconnect(); - const request = yield* Queue.take(h.requests); - expect(request.command.type).toBe("thread.settle"); - // Only a send that follows a recorded mark finds the entry marked. - expect(stored(h.storage)).toMatchObject([{ attempted: true }]); - expect(yield* Queue.size(h.requests)).toBe(0); + yield* Deferred.succeed(request.reply, { sequence: 2 }); + yield* nothingPending(first); }), ); - it.effect("forgets an environment's queued changes in memory when it is removed", () => + it.effect("forgets a removed environment's queued changes", () => Effect.gen(function* () { - const h = yield* makeHarness({ connected: false }); - const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); - h.registry.mount(pending); - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + const h = yield* queued(); yield* h.store.clearEnvironment(ENVIRONMENT_ID); - expect(h.registry.get(pending).size).toBe(0); - expect(h.registry.get(h.visibleAtom)).toBe(SNAPSHOT); - expect(stored(h.storage)).toEqual([]); + yield* h.reconnect(); + yield* nothingPending(h); + yield* expectNothingSent(h); }), ); - it.effect("keeps queued changes to their own environment", () => + it.effect("does not queue a settle the cached thread cannot take", () => Effect.gen(function* () { const h = yield* makeHarness({ connected: false }); - const other = EnvironmentId.make("other-remote"); - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - expect(h.registry.get(h.commands.snapshotAtom(other))).toBe(SNAPSHOT); - expect(h.registry.get(h.commands.pendingSyncAtom(other)).size).toBe(0); + h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), changedThread({ status: "running" })); + const result = yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + expect(result._tag).toBe("Failure"); + expect(stored(h.storage)).toEqual([]); }), ); - it.effect("keeps a queued settle when Undo cannot remove it from storage", () => + it.effect("keeps a queued settle when a snooze of the same thread would replace it", () => Effect.gen(function* () { - const storage = { value: null as string | null, failWrites: false }; - const h = yield* makeHarness({ connected: false, storage }); - const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); - h.registry.mount(pending); - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - storage.failWrites = true; - const undo = yield* Effect.promise(() => - h.commands.unsettle.run(h.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, reason: "user" }, - }), - ); - expect(undo._tag).toBe("Failure"); - expect(h.registry.get(pending).has(THREAD_ID)).toBe(true); - expect(h.registry.get(h.visibleAtom)?.threads[0]?.settledOverride).toBe("settled"); - - storage.failWrites = false; - const restarted = yield* makeHarness({ connected: false, storage }); - const restartedPending = restarted.commands.pendingSyncAtom(ENVIRONMENT_ID); - restarted.registry.mount(restartedPending); - yield* waitFor(restarted.registry, restartedPending, (threads) => threads.has(THREAD_ID)); + const h = yield* queued(); + const before = h.storage.value; + const result = yield* Effect.promise(() => h.commands.snooze.run(h.registry, snooze)); + expect(result._tag).toBe("Failure"); + expect(h.storage.value).toBe(before); }), ); - it.effect("queues the snapshot the action was taken against, not a later one", () => + it.effect("keeps the not-connected failure when the change cannot be saved", () => Effect.gen(function* () { - const loaded = yield* Deferred.make(); const h = yield* makeHarness({ connected: false, - wrapStore: (store) => ({ - ...store, - load: Deferred.await(loaded).pipe(Effect.andThen(store.load)), - }), - }); - const queued = h.commands.settle.run(h.registry, settle); - // The shell moves on while the queue waits for storage. - h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), { - ...SNAPSHOT, - snapshotSequence: 5, - threads: [{ ...SNAPSHOT.threads[0]!, title: "Renamed on the Mac" }], + storage: { value: null, failWrites: true }, }); - yield* Deferred.succeed(loaded, undefined); - expect((yield* Effect.promise(() => queued))._tag).toBe("Success"); - expect(stored(h.storage)).toMatchObject([{ snapshotSequence: 1 }]); - }), - ); - - it.effect("drops a queued change once a newer action reaches the server behind its replay", () => - Effect.gen(function* () { - const h = yield* makeHarness({ connected: false }); - const pending = h.commands.pendingSyncAtom(ENVIRONMENT_ID); - h.registry.mount(pending); - yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); - yield* h.reconnect(); - const replay = yield* Queue.take(h.requests); - expect(replay.command.type).toBe("thread.settle"); - // Starts while the replay still holds the thread, so it waits behind it. - const newer = h.commands.snooze.run(h.registry, snooze); - yield* Deferred.fail( - replay.reply, - new EnvironmentRpcUnavailableError({ - environmentId: ENVIRONMENT_ID, - message: "Mac is not connected.", - }), - ); - const request = yield* Queue.take(h.requests); - expect(request.command.type).toBe("thread.snooze"); - yield* Deferred.succeed(request.reply, { sequence: 2 }); - expect((yield* Effect.promise(() => newer))._tag).toBe("Success"); - expect(h.registry.get(pending).size).toBe(0); - expect(stored(h.storage)).toEqual([]); - }), - ); - - it.effect.each([ - ["settle", "unsettle"], - ["snooze", "unsnooze"], - ] as const)( - "Undo after a restart asks the server once a %s replay lost its reply", - ([action, undo]) => - Effect.gen(function* () { - const h = yield* makeHarness({ connected: false }); - h.registry.mount(h.commands.pendingSyncAtom(ENVIRONMENT_ID)); - yield* Effect.promise(() => h.commands[action].run(h.registry, snooze)); - yield* h.reconnect(); - const replay = yield* Queue.take(h.requests); - yield* Deferred.fail(replay.reply, lostReply()); - const tab = yield* makeHarness({ connected: false, storage: h.storage }); - const pending = tab.commands.pendingSyncAtom(ENVIRONMENT_ID); - tab.registry.mount(pending); - yield* waitFor(tab.registry, pending, (threads) => threads.has(THREAD_ID)); - - // Still offline here, so the server Undo it needs cannot reach the server. - const result = yield* Effect.promise(() => - tab.commands[undo].run(tab.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, reason: "user" }, - }), - ); - expect(result._tag).toBe("Failure"); - expect(stored(h.storage)).toMatchObject([{ action: { type: action }, attempted: true }]); - }), - ); - - it.effect("Undo asks the server for a change that replaced one whose reply was lost", () => - Effect.gen(function* () { - const h = yield* makeHarness({ connected: false }); - h.registry.mount(h.commands.pendingSyncAtom(ENVIRONMENT_ID)); - yield* Effect.promise(() => h.commands.snooze.run(h.registry, snooze)); - yield* h.reconnect(); - const replay = yield* Queue.take(h.requests); - yield* h.disconnect; - yield* Deferred.fail(replay.reply, lostReply()); - const later = { - ...snooze, - input: { ...snooze.input, snoozedUntil: "2099-02-01T00:00:00.000Z" }, - }; - yield* Effect.promise(() => h.commands.snooze.run(h.registry, later)); - expect(stored(h.storage)).toMatchObject([ - { action: { type: "snooze", snoozedUntil: later.input.snoozedUntil }, attempted: true }, - ]); - - const result = yield* Effect.promise(() => - h.commands.unsnooze.run(h.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, reason: "user" }, - }), + const result = yield* Effect.promise(() => h.commands.settle.run(h.registry, settle)); + expect(String(result._tag === "Failure" ? Cause.squash(result.cause) : "")).toContain( + "Mac is not connected.", ); - expect(result._tag).toBe("Failure"); + expect(h.registry.get(h.pendingSyncAtom).size).toBe(0); }), ); }); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 266daa09480fc..9af6bccabfe1b 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -8,7 +8,6 @@ import { type EnvironmentId, type OrchestrationV2ShellSnapshot, type OrchestrationV2ThreadShell, - type ServerConfig, } from "@t3tools/contracts"; import { createOptimisticThreadLifecycle } from "./threadLifecycle.ts"; @@ -50,7 +49,6 @@ import { reorderPinnedThread, reorderActiveThread, setThreadAutoSettle, - requireThreadVersionGuard, settleThread, snoozeThread, startThreadTurn, @@ -119,14 +117,8 @@ function applySnooze( export function createThreadEnvironmentAtoms( runtime: Atom.AtomRuntime, snapshotAtom: (environmentId: EnvironmentId) => Atom.Atom, - options: { - /** Offline settle and snooze replay once this reports a synchronized shell. */ - readonly shellStatusAtom?: (environmentId: EnvironmentId) => Atom.Atom; - /** Whether the loaded session can change threads; null until it loads. */ - readonly operateScopeAtom?: (environmentId: EnvironmentId) => Atom.Atom; - /** Cached config; offline changes queue only for servers that can check them. */ - readonly serverConfigAtom?: (environmentId: EnvironmentId) => Atom.Atom; - } = {}, + /** Offline settle and snooze are sent once this reports a synchronized shell. */ + shellStatusAtom?: (environmentId: EnvironmentId) => Atom.Atom, ) { const scheduler = createAtomCommandScheduler(); const concurrency = { @@ -437,28 +429,20 @@ export function createThreadEnvironmentAtoms( concurrency, }), }; - const versionGuard = createEnvironmentCommand(runtime, { - label: "environment-data:commands:thread:version-guard", - execute: () => requireThreadVersionGuard(), - }); const triage = createThreadTriageOutbox({ runtime, sourceSnapshotAtom: snapshotAtom, - ...(options.shellStatusAtom === undefined ? {} : { shellStatusAtom: options.shellStatusAtom }), - ...(options.serverConfigAtom === undefined - ? {} - : { serverConfigAtom: options.serverConfigAtom }), - applySettle: (thread, queuedAt) => applySettle(thread, queuedAt, false), - applySnooze: (thread, snoozedUntil, queuedAt) => - applySnooze(thread, snoozedUntil, queuedAt, false), - checkServer: (registry, environmentId) => - versionGuard.run(registry, { environmentId, input: undefined }), - replay: (registry, { environmentId, threadId, commandId, action, snapshotSequence }) => + ...(shellStatusAtom === undefined ? {} : { shellStatusAtom }), + apply: (thread, action, queuedAt) => + action.type === "settle" + ? applySettle(thread, queuedAt, false) + : applySnooze(thread, action.snoozedUntil, queuedAt, false), + send: (registry, { environmentId, threadId, commandId, action }) => action.type === "settle" - ? settle.run(registry, { environmentId, input: { threadId, commandId, snapshotSequence } }) + ? settle.run(registry, { environmentId, input: { threadId, commandId } }) : snooze.run(registry, { environmentId, - input: { threadId, commandId, snapshotSequence, snoozedUntil: action.snoozedUntil }, + input: { threadId, commandId, snoozedUntil: action.snoozedUntil }, }), }); const optimistic = createOptimisticThreadLifecycle(triage.snapshotAtom); @@ -473,13 +457,6 @@ export function createThreadEnvironmentAtoms( snapshotAtom: optimistic.snapshotAtom, pendingThreadIdsAtom: optimistic.pendingThreadIdsAtom, pendingSyncAtom: triage.pendingSyncAtom, - /** Until a session loads, offline settle and snooze stay available; replay is checked again. */ - offlineTriagePermittedAtom: Atom.family((environmentId: EnvironmentId) => - Atom.make((get) => { - const operateScopeAtom = options.operateScopeAtom?.(environmentId); - return operateScopeAtom === undefined || get(operateScopeAtom) !== false; - }), - ), settle: triage.queueWhenOffline(settle, () => ({ type: "settle" as const })), unsettle: triage.cancelQueued( optimistic.wrap(commands.unsettle, (thread, input, now) => ({ diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index 1257f14af110e..c8f10b1d89709 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -3,7 +3,6 @@ import { type EnvironmentId, type OrchestrationV2ShellSnapshot, type OrchestrationV2ThreadShell, - type ServerConfig, type ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -15,57 +14,20 @@ import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; import { type PendingThreadTriage, - type ThreadTriageKey, - type ThreadTriageOutboxDocument, - sameThreadTriage, + type PendingThreadTriages, ThreadTriageOutboxStore, type ThreadTriageOutboxStorageError, - ThreadTriagePendingSettleError, } from "../platform/threadTriageOutboxStore.ts"; -import { ThreadVersionGuardUnsupportedError } from "../operations/commands.ts"; import { EnvironmentRpcUnavailableError, isRpcClientError } from "../rpc/client.ts"; import type { EnvironmentShellStatus } from "./shell.ts"; import { type AtomCommand, type AtomCommandResult, createRuntimeCommand } from "./runtime.ts"; -type SettleOrSnoozeInput = { - readonly threadId: ThreadId; - readonly commandId?: CommandId; - readonly snoozedUntil?: string; - readonly snapshotSequence?: number; -}; -type TriageResult = { readonly sequence: number; readonly pendingSync?: true }; -type Command = AtomCommand< - { readonly environmentId: EnvironmentId; readonly input: I }, - TriageResult, - E ->; -type StoredEntry = ThreadTriageKey & Pick; - -type StoreOperation = - | { readonly _tag: "load" | "refresh" } - | { readonly _tag: "put"; readonly entry: Omit } - | { readonly _tag: "remove"; readonly entry: StoredEntry } - | { readonly _tag: "markAttempted"; readonly entry: PendingThreadTriage }; - -const EMPTY_DOCUMENT: ThreadTriageOutboxDocument = { entries: [] }; +type Target = { readonly environmentId: EnvironmentId; readonly input: I }; +type Change = (stored: PendingThreadTriages, newCommandId: CommandId) => PendingThreadTriages; const isUnavailable = Schema.is(EnvironmentRpcUnavailableError); -const isGuardUnsupported = Schema.is(ThreadVersionGuardUnsupportedError); -const isPendingSettle = Schema.is(ThreadTriagePendingSettleError); - -function offlineError(cause: Cause.Cause) { - const reason = cause.reasons.find( - (candidate) => Cause.isFailReason(candidate) && isUnavailable(candidate.error), - ); - return reason !== undefined && Cause.isFailReason(reason) && isUnavailable(reason.error) - ? reason.error - : undefined; -} -/** - * Failures that say nothing about whether the server would accept the change. A server too old - * to check the snapshot keeps the change queued until it is updated. - */ +/** Failures that say nothing about whether the server would accept the change. */ function isTransient(cause: Cause.Cause) { return ( Cause.hasInterruptsOnly(cause) || @@ -73,166 +35,81 @@ function isTransient(cause: Cause.Cause) { (reason) => !Cause.isFailReason(reason) || isUnavailable(reason.error) || - isRpcClientError(reason.error) || - isGuardUnsupported(reason.error), + isRpcClientError(reason.error), ) ); } -const sameEntry = (a: StoredEntry, b: StoredEntry) => - sameThreadTriage(a, b) && a.commandId === b.commandId; +/** What the user acted on. A thread that shows otherwise on reconnect keeps its server state. */ +function seenState(thread: OrchestrationV2ThreadShell) { + return JSON.stringify([ + thread.latestRunId, + thread.status, + thread.itemCount, + thread.pendingRuntimeRequest?.id ?? null, + thread.settledOverride, + thread.snoozedUntil == null ? null : DateTime.toEpochMillis(thread.snoozedUntil), + thread.pinnedAt != null, + ]); +} -/** Keeps the array when nothing changes, so a no-op does not wake the replay again. */ -function withoutEntry(entries: ReadonlyArray, entry: StoredEntry) { - return entries.some((current) => sameEntry(current, entry)) - ? entries.filter((current) => !sameEntry(current, entry)) +const without = (entries: PendingThreadTriages, commandId: CommandId) => + entries.some((entry) => entry.commandId === commandId) + ? entries.filter((entry) => entry.commandId !== commandId) : entries; -} +const sameEntries = (a: PendingThreadTriages, b: PendingThreadTriages) => + a.length === b.length && + a.every( + (entry, index) => + entry.commandId === b[index]!.commandId && entry.attempted === b[index]!.attempted, + ); + +/** + * Settle and snooze for an environment that is not connected: the change is stored on the + * device, shown at once, and sent when the environment is back. Storage is the truth: memory + * mirrors what the last write returned, and a change is confirmed there right before it is sent. + */ export function createThreadTriageOutbox(input: { readonly runtime: Atom.AtomRuntime; readonly sourceSnapshotAtom: ( environmentId: EnvironmentId, ) => Atom.Atom; - /** Replay waits for a synchronized shell; without one, nothing replays. */ + /** Queued changes are sent once this reports a synchronized shell; without it, never. */ readonly shellStatusAtom?: (environmentId: EnvironmentId) => Atom.Atom; - /** Cached config; only servers that check the snapshot sequence get queued changes. */ - readonly serverConfigAtom?: (environmentId: EnvironmentId) => Atom.Atom; - readonly applySettle: ( - thread: OrchestrationV2ThreadShell, - queuedAt: DateTime.Utc, - ) => OrchestrationV2ThreadShell; - readonly applySnooze: ( + /** Returns the thread itself when the action cannot apply to it. */ + readonly apply: ( thread: OrchestrationV2ThreadShell, - snoozedUntil: string, + action: PendingThreadTriage["action"], queuedAt: DateTime.Utc, ) => OrchestrationV2ThreadShell; - /** Fails before anything is sent when the connected server cannot check the snapshot. */ - readonly checkServer: ( - registry: AtomRegistry.AtomRegistry, - environmentId: EnvironmentId, - ) => Promise>; - /** Sends the queued action with its command id and snapshot sequence. */ - readonly replay: ( + /** Sends the action under its command id, which the server answers only once. */ + readonly send: ( registry: AtomRegistry.AtomRegistry, entry: PendingThreadTriage, ) => Promise>; }) { - const store = createRuntimeCommand(input.runtime, { + const update = createRuntimeCommand(input.runtime, { label: "environment-data:thread-triage-outbox", - execute: Effect.fn("ThreadTriageOutbox.store")(function* ( - operation: StoreOperation, - registry: AtomRegistry.AtomRegistry, - ) { - const outbox = yield* ThreadTriageOutboxStore; - const logFailure = - (message: string) => - (effect: Effect.Effect) => - effect.pipe(Effect.tapError(() => Effect.logWarning(message, { reason: "storage" }))); - switch (operation._tag) { - case "refresh": - return yield* outbox.load; - case "load": - outbox.subscribeCleared((environmentId) => clearLocal(registry, environmentId)); - return yield* outbox.load.pipe( - Effect.catch(() => - Effect.logWarning("Could not load offline thread changes.", { - reason: "storage", - }).pipe(Effect.as(EMPTY_DOCUMENT)), - ), - ); - case "put": { - const random = yield* SecureRandom.SecureRandom; - const entry = { - ...operation.entry, - commandId: CommandId.make(yield* random.uuidV4()), - }; - yield* outbox.put(entry).pipe( - Effect.tapError((error) => - error._tag === "ThreadTriagePendingSettleError" - ? Effect.void - : Effect.logWarning("Could not save an offline thread change.", { - reason: "storage", - }), - ), - ); - return { entries: [entry] }; - } - case "remove": - yield* outbox - .remove(operation.entry) - .pipe(logFailure("Could not remove an offline thread change.")); - return EMPTY_DOCUMENT; - case "markAttempted": { - const marked = yield* outbox - .markAttempted(operation.entry) - .pipe(logFailure("Could not record an offline thread change as sent.")); - return { entries: marked ? [operation.entry] : [] }; - } - } - }), - }); - - const cancelStored = createRuntimeCommand(input.runtime, { - label: "environment-data:thread-triage-outbox-cancel", - execute: Effect.fn("ThreadTriageOutbox.cancel")(function* (entry: PendingThreadTriage) { - const outbox = yield* ThreadTriageOutboxStore; - return yield* outbox - .cancel(entry) + execute: Effect.fn("ThreadTriageOutbox.update")(function* (change: Change) { + const store = yield* ThreadTriageOutboxStore; + const random = yield* SecureRandom.SecureRandom; + const newCommandId = CommandId.make(yield* random.uuidV4()); + return yield* store + .update((stored) => change(stored, newCommandId)) .pipe( Effect.tapError(() => - Effect.logWarning("Could not cancel an offline thread change.", { reason: "storage" }), + Effect.logWarning("Could not read or save offline thread changes.", { + reason: "storage", + }), ), ); }), }); - const documentAtom = Atom.make(null).pipe(Atom.keepAlive); - let loading: Promise | undefined; - const ensureLoaded = (registry: AtomRegistry.AtomRegistry) => { - const loaded = registry.get(documentAtom); - if (loaded !== null) return Promise.resolve(loaded); - loading ??= store.run(registry, { _tag: "load" }).then((result) => { - const current = - registry.get(documentAtom) ?? - (AsyncResult.isSuccess(result) ? result.value : EMPTY_DOCUMENT); - registry.set(documentAtom, current); - return current; - }); - return loading; - }; - const loadedDocumentAtom = Atom.make((get) => { - const loaded = get(documentAtom); - if (loaded === null) void ensureLoaded(get.registry); - return loaded; - }); - - const entriesAtom = Atom.make((get) => get(loadedDocumentAtom)?.entries ?? []); - const environmentEntriesAtom = Atom.family((environmentId: EnvironmentId) => - Atom.make((get) => get(entriesAtom).filter((entry) => entry.environmentId === environmentId)), - ); - - const pendingFor = (registry: AtomRegistry.AtomRegistry, key: ThreadTriageKey) => - registry.get(documentAtom)?.entries.find((entry) => sameThreadTriage(entry, key)); - const setLocal = ( - registry: AtomRegistry.AtomRegistry, - transform: (entries: ReadonlyArray) => ReadonlyArray, - ) => { - const document = registry.get(documentAtom) ?? EMPTY_DOCUMENT; - const entries = transform(document.entries); - if (entries !== document.entries) registry.set(documentAtom, { entries }); - }; - /** Counts removals, so a queue write that lands after one is undone. */ - const removals = new Map(); - const clearLocal = (registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId) => { - removals.set(environmentId, (removals.get(environmentId) ?? 0) + 1); - if (registry.get(documentAtom) === null) return; - setLocal(registry, (entries) => - entries.filter((entry) => entry.environmentId !== environmentId), - ); - }; - /** Ends with the registry, so replay stops touching it once the app or a test tears it down. */ - const registryLifetimeAtom = Atom.make((get) => { + const entriesAtom = Atom.make(null).pipe(Atom.keepAlive); + /** Ends with the registry, so a replay in flight stops touching it. */ + const lifetimeAtom = Atom.make((get) => { const lifetime = { ended: false }; get.addFinalizer(() => { lifetime.ended = true; @@ -240,19 +117,50 @@ export function createThreadTriageOutbox(input: { return lifetime; }).pipe(Atom.keepAlive); - /** - * Hides the entry even if storage keeps it: a restart then replays it with the same command - * id, which the server deduplicates or rejects. Cancelling must check the result instead. - */ - const forget = async (registry: AtomRegistry.AtomRegistry, entry: StoredEntry) => { - const lifetime = registry.get(registryLifetimeAtom); - const removed = await store.run(registry, { _tag: "remove", entry }); - if (!lifetime.ended) setLocal(registry, (entries) => withoutEntry(entries, entry)); - return removed; + const write = async ( + registry: AtomRegistry.AtomRegistry, + change: Change = (stored) => stored, + ) => { + const lifetime = registry.get(lifetimeAtom); + const result = await update.run(registry, change); + if (AsyncResult.isSuccess(result) && !lifetime.ended) { + const current = registry.get(entriesAtom); + // An unchanged queue must not wake the replay that just read it. + if (current === null || !sameEntries(current, result.value)) { + registry.set(entriesAtom, result.value); + } + } + return result; }; + const forget = (registry: AtomRegistry.AtomRegistry, entry: PendingThreadTriage) => + write(registry, (stored) => without(stored, entry.commandId)); + + let loading: Promise | undefined; + const load = (registry: AtomRegistry.AtomRegistry) => (loading ??= write(registry)); + const environmentEntriesAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const entries = get(entriesAtom); + if (entries === null) void load(get.registry); + return (entries ?? []).filter((entry) => entry.environmentId === environmentId); + }), + ); + const pendingFor = ( + registry: AtomRegistry.AtomRegistry, + environmentId: EnvironmentId, + threadId: ThreadId, + ) => + registry + .get(entriesAtom) + ?.find((entry) => entry.environmentId === environmentId && entry.threadId === threadId); + const sourceThread = ( + registry: AtomRegistry.AtomRegistry, + environmentId: EnvironmentId, + threadId: ThreadId, + ) => + registry + .get(input.sourceSnapshotAtom(environmentId)) + ?.threads.find((thread) => thread.id === threadId); - /** Answered this session; storage that failed to drop one must not make replay send it again. */ - const answered = new Set(); const replaying = new Set(); const replayAgain = new Set(); const isLive = (registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId) => @@ -263,7 +171,7 @@ export function createThreadTriageOutbox(input: { replayAgain.add(environmentId); return; } - const lifetime = registry.get(registryLifetimeAtom); + const lifetime = registry.get(lifetimeAtom); replaying.add(environmentId); try { await replayEntries(registry, environmentId, lifetime); @@ -279,57 +187,42 @@ export function createThreadTriageOutbox(input: { environmentId: EnvironmentId, lifetime: { readonly ended: boolean }, ) => { - await ensureLoaded(registry); - // Storage decides what to send: another tab may have queued, replaced, or cancelled a change. - const loaded = await store.run(registry, { _tag: "refresh" }); - if (lifetime.ended || !AsyncResult.isSuccess(loaded)) return; - const stored = loaded.value.entries.filter( - (entry) => entry.environmentId === environmentId && !answered.has(entry.commandId), - ); - setLocal(registry, (entries) => { - const kept = entries.filter( - (entry) => - entry.environmentId !== environmentId || - stored.some((candidate) => sameEntry(candidate, entry)), - ); - const added = stored.filter((entry) => !entries.some((current) => sameEntry(current, entry))); - return kept.length === entries.length && added.length === 0 ? entries : [...kept, ...added]; - }); - if (stored.length === 0) return; - // An older server would refuse every entry, which must stay unmarked so Undo can cancel it. - const server = await input.checkServer(registry, environmentId); - if (lifetime.ended || !AsyncResult.isSuccess(server)) return; - for (const entry of stored) { + for (const entry of registry.get(entriesAtom) ?? []) { + if (lifetime.ended) return; + if (entry.environmentId !== environmentId) continue; + const thread = sourceThread(registry, environmentId, entry.threadId); + // The live shell is the server's state. It wins over a change made against an older one, + // which also covers a change that was sent before but whose reply was lost. if ( - entry.action.type === "snooze" && - !(Date.parse(entry.action.snoozedUntil) > DateTime.toEpochMillis(DateTime.nowUnsafe())) + thread === undefined || + seenState(thread) !== entry.seen || + input.apply(thread, entry.action, DateTime.nowUnsafe()) === thread ) { - answered.add(entry.commandId); await forget(registry, entry); - if (lifetime.ended) return; continue; } // Marked before sending, so an Undo after a lost reply or a restart asks the server. - const marked = await store.run(registry, { _tag: "markAttempted", entry }); + const marked = await write(registry, (current) => + current.some((item) => item.commandId === entry.commandId && item.attempted !== true) + ? current.map((item) => + item.commandId === entry.commandId ? { ...item, attempted: true as const } : item, + ) + : current, + ); if (lifetime.ended || !AsyncResult.isSuccess(marked)) return; - // Cancelled or replaced after storage was read. - if (marked.value.entries.length === 0) { - setLocal(registry, (entries) => withoutEntry(entries, entry)); - continue; - } - const result = await input.replay(registry, entry); - if (lifetime.ended) return; - if (!AsyncResult.isSuccess(result) && isTransient(result.cause)) return; - answered.add(entry.commandId); + // Storage no longer has it: cancelled, replaced, sent by another tab, or its environment + // was removed. + if (!marked.value.some((item) => item.commandId === entry.commandId)) continue; + const result = await input.send(registry, entry); + if (lifetime.ended || (!AsyncResult.isSuccess(result) && isTransient(result.cause))) return; await forget(registry, entry); - if (lifetime.ended) return; } }; - const replayAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => { - get(environmentEntriesAtom(environmentId)); + const entries = get(environmentEntriesAtom(environmentId)); if ( + entries.length > 0 && input.shellStatusAtom !== undefined && get(input.shellStatusAtom(environmentId)) === "live" ) @@ -340,8 +233,6 @@ export function createThreadTriageOutbox(input: { const snapshotAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => { get.mount(replayAtom(environmentId)); - // Loads the cached config before an offline action needs to read it. - if (input.serverConfigAtom) get.mount(input.serverConfigAtom(environmentId)); const snapshot = get(input.sourceSnapshotAtom(environmentId)); const entries = get(environmentEntriesAtom(environmentId)); if (snapshot === null || entries.length === 0) return snapshot; @@ -350,10 +241,7 @@ export function createThreadTriageOutbox(input: { ...snapshot, threads: snapshot.threads.map((thread) => { const entry = byThread.get(thread.id); - if (entry === undefined) return thread; - return entry.action.type === "settle" - ? input.applySettle(thread, entry.queuedAt) - : input.applySnooze(thread, entry.action.snoozedUntil, entry.queuedAt); + return entry === undefined ? thread : input.apply(thread, entry.action, entry.queuedAt); }), }; }), @@ -366,124 +254,79 @@ export function createThreadTriageOutbox(input: { ), ); - /** Queues the action locally when the environment is not connected. */ - function queueWhenOffline( - command: Command, - action: (input: I) => PendingThreadTriage["action"], - ): Command { + /** Queues the action on the device when its environment is not connected. */ + function queueWhenOffline( + command: AtomCommand, { readonly sequence: number }, CE>, + toAction: (input: I) => PendingThreadTriage["action"], + ): typeof command { return { label: command.label, run: async (registry, target) => { const { environmentId } = target; - const key = { environmentId, threadId: target.input.threadId }; - const removalsBefore = removals.get(environmentId) ?? 0; - // The server compares against what the user saw when acting, not what arrives later. + const { threadId } = target.input; + // Read before sending: the change is judged against what the user saw when acting. const snapshot = registry.get(input.sourceSnapshotAtom(environmentId)); - const thread = snapshot?.threads.find((candidate) => candidate.id === key.threadId); - const guarded = - (input.serverConfigAtom ? registry.get(input.serverConfigAtom(environmentId)) : null) - ?.environment.capabilities.threadTriageVersionGuard === true; - // Started before any await so the optimistic change shows in the same frame. - const result = await command.run( - registry, - snapshot !== null && - guarded && - input.shellStatusAtom !== undefined && - registry.get(input.shellStatusAtom(environmentId)) !== "live" - ? { - ...target, - input: { ...target.input, snapshotSequence: snapshot.snapshotSequence }, - } - : target, - ); + const thread = snapshot?.threads.find((candidate) => candidate.id === threadId); + const result = await command.run(registry, target); if (AsyncResult.isSuccess(result)) { - // The server has a newer action, so its guard would reject a queued one anyway. - const previous = pendingFor(registry, key); + // The server now has a newer action than the queued one. + const previous = pendingFor(registry, environmentId, threadId); if (previous !== undefined) await forget(registry, previous); return result; } - const offline = offlineError(result.cause); - if (offline === undefined) return result; - if (!guarded) { - return AsyncResult.failure( - Cause.fail( - new ThreadVersionGuardUnsupportedError({ - message: `${offline.message} Update its server to settle or snooze while it is offline.`, - }), - ), - ); - } - await ensureLoaded(registry); - if (snapshot == null || thread === undefined) return result; - const queuedAction = action(target.input); - const now = DateTime.nowUnsafe(); + const offline = result.cause.reasons.some( + (reason) => Cause.isFailReason(reason) && isUnavailable(reason.error), + ); + if (!offline || snapshot == null || thread === undefined) return result; + await load(registry); + const action = toAction(target.input); + const queuedAt = DateTime.nowUnsafe(); if ( - (queuedAction.type === "settle" - ? input.applySettle(thread, now) - : input.applySnooze(thread, queuedAction.snoozedUntil, now)) === thread + input.apply(thread, action, queuedAt) === thread || + // One change per thread. A snooze cannot replace a settle, which online it keeps. + (action.type === "snooze" && + pendingFor(registry, environmentId, threadId)?.action.type === "settle") ) return result; - // The put replaces any earlier entry for the thread, so a failed save keeps it. - const queued = await store.run(registry, { - _tag: "put", - entry: { - ...key, - action: queuedAction, - queuedAt: now, - snapshotSequence: snapshot.snapshotSequence, - }, - }); - if (!AsyncResult.isSuccess(queued)) { - return queued.cause.reasons.some( - (reason) => Cause.isFailReason(reason) && isPendingSettle(reason.error), - ) - ? AsyncResult.failure(Cause.fail(new ThreadTriagePendingSettleError())) - : result; - } - const entry = queued.value.entries[0]!; - if ((removals.get(environmentId) ?? 0) !== removalsBefore) { - await forget(registry, entry); - return result; - } - setLocal(registry, (entries) => [ - ...entries.filter((existing) => !sameThreadTriage(existing, entry)), - entry, + const queued = await write(registry, (stored, commandId) => [ + ...stored.filter( + (entry) => entry.environmentId !== environmentId || entry.threadId !== threadId, + ), + { environmentId, threadId, commandId, action, queuedAt, seen: seenState(thread) }, ]); - return AsyncResult.success({ sequence: snapshot.snapshotSequence, pendingSync: true }); + return AsyncResult.isSuccess(queued) + ? AsyncResult.success({ sequence: snapshot.snapshotSequence }) + : result; }, }; } /** - * Undo of a change that was never sent cancels it locally. Once it was sent, even if the reply - * was lost, only the server can undo it. + * Undo of a change that was never sent cancels it on the device. Once it was sent, even if the + * reply was lost, only the server can undo it. */ function cancelQueued( - command: AtomCommand<{ readonly environmentId: EnvironmentId; readonly input: I }, A, CE>, + command: AtomCommand, A, CE>, cancels: PendingThreadTriage["action"]["type"], cancelledValue: B, - ): AtomCommand< - { readonly environmentId: EnvironmentId; readonly input: I }, - A | B, - CE | ThreadTriageOutboxStorageError | E - > { + ): AtomCommand, A | B, CE | ThreadTriageOutboxStorageError | E> { return { label: command.label, run: async (registry, target) => { - const pending = pendingFor(registry, { - environmentId: target.environmentId, - threadId: target.input.threadId, - }); + const pending = pendingFor(registry, target.environmentId, target.input.threadId); if (pending === undefined || pending.action.type !== cancels) { return command.run(registry, target); } - // Storage decides, because a replay, a restart, or another tab may have sent it. - const outcome = await cancelStored.run(registry, pending); - if (!AsyncResult.isSuccess(outcome)) return AsyncResult.failure(outcome.cause); - if (outcome.value !== "attempted") { - setLocal(registry, (entries) => withoutEntry(entries, pending)); - } - if (outcome.value === "cancelled") return AsyncResult.success(cancelledValue); + // Storage decides, because a replay, a restart or another tab may have sent it. + let unsent = false; + const stored = await write(registry, (current) => { + unsent = current.some( + (entry) => entry.commandId === pending.commandId && entry.attempted !== true, + ); + return unsent ? without(current, pending.commandId) : current; + }); + if (!AsyncResult.isSuccess(stored)) return AsyncResult.failure(stored.cause); + if (unsent) return AsyncResult.success(cancelledValue); const result = await command.run(registry, target); if (AsyncResult.isSuccess(result)) await forget(registry, pending); return result; @@ -491,10 +334,5 @@ export function createThreadTriageOutbox(input: { }; } - return { - snapshotAtom, - pendingSyncAtom, - queueWhenOffline, - cancelQueued, - }; + return { snapshotAtom, pendingSyncAtom, queueWhenOffline, cancelQueued }; } diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 1faa9f28aa956..5e9cca70752b6 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -109,8 +109,6 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Hide the switch when absent; older servers silently drop per-project provider patches. */ projectProviderInstanceOverrides: Schema.optionalKey(Schema.Boolean), threadSnooze: Schema.optionalKey(Schema.Boolean), - /** Settle and snooze honor `snapshotSequence`; older servers ignore it. */ - threadTriageVersionGuard: Schema.optionalKey(Schema.Boolean), /** Serves `orchestration.forceStopThread`; hide Force stop where it is absent. */ threadForceStop: Schema.optionalKey(Schema.Boolean), /** Every protocol-2 server sends this; older mobile builds still read it. */ diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 14ada662b1ff0..856cf89bafcb6 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3000,8 +3000,6 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, expectedLatestRunId: Schema.optional(RunId), settledAt: Schema.optional(Schema.DateTimeUtc), - /** Rejects the command if the thread changed after this shell sequence; a session stop does not count. */ - snapshotSequence: Schema.optional(NonNegativeInt), }), /** Rejects changes since snapshotAt or any explicit settled override to preserve user actions. */ Schema.Struct({ @@ -3022,8 +3020,6 @@ export const OrchestrationV2Command = Schema.Union([ commandId: CommandId, threadId: ThreadId, snoozedUntil: IsoDateTime, - /** Rejects the command if the thread changed after this shell sequence; a session stop does not count. */ - snapshotSequence: Schema.optional(NonNegativeInt), }), Schema.Struct({ type: Schema.Literal("thread.unsnooze"), From 0ab8f1810bdc428b82669f3d471bfcd4bfbf7705 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:19:05 +0000 Subject: [PATCH 38/44] fix(client-runtime): judge queued changes at send time and by every triage decision - Check the shell is still synchronized and compare the thread after the sent mark is stored, so a reconnect during that write cannot send a change judged against an older shell. - Record triage timestamps in what the user acted on, so a settle or snooze made and undone on another device still drops a queued change. - Decide in storage whether a snooze would replace a queued settle, so a settle queued in another tab is kept. - Keep a loaded session's denial after a failed refresh. - shellStatusAtom is required; every caller passes it. Co-Authored-By: Claude Fable 5.2 --- apps/mobile/src/state/session.test.ts | 18 +++++ apps/mobile/src/state/session.ts | 7 +- apps/web/src/state/session.ts | 7 +- .../src/state/threadCommands.test.ts | 56 ++++++++++--- .../src/state/threadCommands.ts | 4 +- .../src/state/threadTriageOutbox.ts | 79 ++++++++++--------- 6 files changed, 115 insertions(+), 56 deletions(-) diff --git a/apps/mobile/src/state/session.test.ts b/apps/mobile/src/state/session.test.ts index b47054e258663..5579da2bcc9ac 100644 --- a/apps/mobile/src/state/session.test.ts +++ b/apps/mobile/src/state/session.test.ts @@ -5,6 +5,7 @@ import { type AuthSessionState, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; @@ -33,6 +34,7 @@ import { appAtomRegistry } from "./atom-registry"; import { environmentSession, readEnvironmentScope, + readThreadTriageScope, useEnvironmentScope, useEnvironmentsWithScope, } from "./session"; @@ -100,6 +102,22 @@ it("reacts to grant revocation, failure, and regrant without a connection list c expect(changes).not.toHaveLength(0); }); +it("lets settle and snooze queue until a loaded session denies them, also after a failed refresh", () => { + const failedAfter = (canOperate: boolean) => + AsyncResult.failure(Cause.fail(new Error("Session lookup failed")), { + previousSuccess: Option.some(AsyncResult.success(session(canOperate))), + }); + expect(readThreadTriageScope(secondary)).toBe(true); + appAtomRegistry.set(source(secondary), AsyncResult.failure(Cause.fail(new Error("offline")))); + expect(readThreadTriageScope(secondary)).toBe(true); + appAtomRegistry.set(source(secondary), AsyncResult.success(session(false))); + expect(readThreadTriageScope(secondary)).toBe(false); + appAtomRegistry.set(source(secondary), failedAfter(false)); + expect(readThreadTriageScope(secondary)).toBe(false); + appAtomRegistry.set(source(secondary), failedAfter(true)); + expect(readThreadTriageScope(secondary)).toBe(true); +}); + it("uses exact new-server permissions and keeps old-server grants usable", () => { appAtomRegistry.set(source(primary), AsyncResult.success({ ...session(true), permissions: [] })); expect(useEnvironmentScope(primary, AuthOrchestrationOperateScope)).toBe(false); diff --git a/apps/mobile/src/state/session.ts b/apps/mobile/src/state/session.ts index 4e4e68cc0f052..48b9ace054dd8 100644 --- a/apps/mobile/src/state/session.ts +++ b/apps/mobile/src/state/session.ts @@ -74,10 +74,9 @@ export function readEnvironmentScope( function sessionAllowsThreadTriage( result: AsyncResult.AsyncResult, ): boolean { - return ( - !AsyncResult.isSuccess(result) || - sessionGrantsScope(result.value, AuthOrchestrationOperateScope) - ); + // A failed refresh still carries the last loaded session, whose answer stands. + const session = AsyncResult.getOrElse(result, () => null); + return session === null || sessionGrantsScope(session, AuthOrchestrationOperateScope); } export function useThreadTriageScope(environmentId: EnvironmentId | null): boolean { diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts index 370025e2f02d6..7509f93da5e0d 100644 --- a/apps/web/src/state/session.ts +++ b/apps/web/src/state/session.ts @@ -81,10 +81,9 @@ export function readEnvironmentScope( function sessionAllowsThreadTriage( result: AsyncResult.AsyncResult, ): boolean { - return ( - !AsyncResult.isSuccess(result) || - sessionGrantsScope(result.value, AuthOrchestrationOperateScope) - ); + // A failed refresh still carries the last loaded session, whose answer stands. + const session = AsyncResult.getOrElse(result, () => null); + return session === null || sessionGrantsScope(session, AuthOrchestrationOperateScope); } export function useThreadTriageScope(environmentId: EnvironmentId | null): boolean { diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 3ec8dc78cb07f..35999c99ca60a 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -103,7 +103,7 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio readonly reconcilePendingWork?: (threadId: ThreadId) => Effect.Effect; readonly connected?: boolean; /** Shared between harnesses to stand in for a restart. */ - readonly storage?: { value: string | null; failWrites?: boolean }; + readonly storage?: { value: string | null; failWrites?: boolean; onModify?: () => void }; }) { const requests = yield* Queue.unbounded<{ command: OrchestrationV2Command; @@ -134,6 +134,7 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio const storage = options?.storage ?? { value: null }; const store = makeThreadTriageOutboxStore((next) => Effect.suspend(() => { + storage.onModify?.(); const value = next(storage.value); if (value === null) return Effect.void; if (storage.failWrites) { @@ -647,7 +648,7 @@ describe("settle and snooze while the environment is offline", () => { }); const queued = Effect.fn("queued")(function* ( action: "settle" | "snooze" = "settle", - storage?: { value: string | null }, + storage?: { value: string | null; onModify?: () => void }, ) { const h = yield* makeHarness({ connected: false, ...(storage ? { storage } : {}) }); const result = yield* Effect.promise(() => h.commands[action].run(h.registry, snooze)); @@ -721,6 +722,7 @@ describe("settle and snooze while the environment is offline", () => { ["produced new output", { itemCount: 1 }], ["started a run", { latestRunId: RunId.make("later-run") }], ["was pinned on another device", { pinnedAt: NOW }], + ["was settled and un-settled on another device", { unsettledAt: NOW }], ["was settled on another device", { settledOverride: "settled" as const, settledAt: NOW }], ["is asking for approval", { pendingRuntimeRequest: APPROVAL }], ] as const)("keeps the server's state for a thread that %s meanwhile", ([, change]) => @@ -738,6 +740,30 @@ describe("settle and snooze while the environment is offline", () => { }), ); + it.effect( + "does not send once the shell stops being synchronized while the change is marked", + () => + Effect.gen(function* () { + const marking = yield* Queue.unbounded(); + const storage: { value: string | null; onModify?: () => void } = { value: null }; + const h = yield* queued("settle", storage); + // A reconnect whose shell has not synchronized yet lands during the storage write. + storage.onModify = () => { + h.registry.set(h.statusAtom(ENVIRONMENT_ID), "synchronizing"); + Queue.offerUnsafe(marking, undefined); + }; + yield* h.reconnect(); + yield* Queue.take(marking); + delete storage.onModify; + // Undo reads storage after the mark, so its request can only follow a settle that was sent. + const undone = h.commands.unsettle.run(h.registry, undo); + const request = yield* Queue.take(h.requests); + expect(request.command.type).toBe("thread.unsettle"); + yield* Deferred.succeed(request.reply, { sequence: 2 }); + expect((yield* Effect.promise(() => undone))._tag).toBe("Success"); + }), + ); + it.effect("sends a queued change again when the first send got no answer", () => Effect.gen(function* () { const h = yield* queued(); @@ -882,14 +908,24 @@ describe("settle and snooze while the environment is offline", () => { }), ); - it.effect("keeps a queued settle when a snooze of the same thread would replace it", () => - Effect.gen(function* () { - const h = yield* queued(); - const before = h.storage.value; - const result = yield* Effect.promise(() => h.commands.snooze.run(h.registry, snooze)); - expect(result._tag).toBe("Failure"); - expect(h.storage.value).toBe(before); - }), + // The other tab loaded before the settle was queued, so only storage knows about it. + it.effect.each(["this tab", "another tab"] as const)( + "keeps a settle queued in %s when a snooze of the same thread would replace it", + (tab) => + Effect.gen(function* () { + const storage = { value: null as string | null }; + const other = yield* makeHarness({ connected: false, storage }); + // Store calls run in order, so the other tab's first read is done after this one. + yield* other.store.update((stored) => stored); + const h = yield* queued("settle", storage); + const before = storage.value; + const snoozing = tab === "this tab" ? h : other; + const result = yield* Effect.promise(() => + snoozing.commands.snooze.run(snoozing.registry, snooze), + ); + expect(result._tag).toBe("Failure"); + expect(storage.value).toBe(before); + }), ); it.effect("keeps the not-connected failure when the change cannot be saved", () => diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 9af6bccabfe1b..22c8145ac67ec 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -118,7 +118,7 @@ export function createThreadEnvironmentAtoms( runtime: Atom.AtomRuntime, snapshotAtom: (environmentId: EnvironmentId) => Atom.Atom, /** Offline settle and snooze are sent once this reports a synchronized shell. */ - shellStatusAtom?: (environmentId: EnvironmentId) => Atom.Atom, + shellStatusAtom: (environmentId: EnvironmentId) => Atom.Atom, ) { const scheduler = createAtomCommandScheduler(); const concurrency = { @@ -432,7 +432,7 @@ export function createThreadEnvironmentAtoms( const triage = createThreadTriageOutbox({ runtime, sourceSnapshotAtom: snapshotAtom, - ...(shellStatusAtom === undefined ? {} : { shellStatusAtom }), + shellStatusAtom, apply: (thread, action, queuedAt) => action.type === "settle" ? applySettle(thread, queuedAt, false) diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index c8f10b1d89709..c2c164c74e27b 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -40,7 +40,11 @@ function isTransient(cause: Cause.Cause) { ); } -/** What the user acted on. A thread that shows otherwise on reconnect keeps its server state. */ +/** + * What the user acted on: the thread's work, and every triage decision with its time, so a + * settle or snooze made and undone elsewhere still counts. A thread that shows otherwise on + * reconnect keeps its server state. + */ function seenState(thread: OrchestrationV2ThreadShell) { return JSON.stringify([ thread.latestRunId, @@ -48,8 +52,13 @@ function seenState(thread: OrchestrationV2ThreadShell) { thread.itemCount, thread.pendingRuntimeRequest?.id ?? null, thread.settledOverride, - thread.snoozedUntil == null ? null : DateTime.toEpochMillis(thread.snoozedUntil), - thread.pinnedAt != null, + ...[ + thread.settledAt, + thread.unsettledAt, + thread.snoozedUntil, + thread.snoozedAt, + thread.pinnedAt, + ].map((time) => (time == null ? null : DateTime.toEpochMillis(time))), ]); } @@ -75,8 +84,8 @@ export function createThreadTriageOutbox(input: { readonly sourceSnapshotAtom: ( environmentId: EnvironmentId, ) => Atom.Atom; - /** Queued changes are sent once this reports a synchronized shell; without it, never. */ - readonly shellStatusAtom?: (environmentId: EnvironmentId) => Atom.Atom; + /** Queued changes are sent once this reports a synchronized shell. */ + readonly shellStatusAtom: (environmentId: EnvironmentId) => Atom.Atom; /** Returns the thread itself when the action cannot apply to it. */ readonly apply: ( thread: OrchestrationV2ThreadShell, @@ -164,7 +173,6 @@ export function createThreadTriageOutbox(input: { const replaying = new Set(); const replayAgain = new Set(); const isLive = (registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId) => - input.shellStatusAtom !== undefined && registry.get(input.shellStatusAtom(environmentId)) === "live"; const replay = async (registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId) => { if (replaying.has(environmentId)) { @@ -190,17 +198,6 @@ export function createThreadTriageOutbox(input: { for (const entry of registry.get(entriesAtom) ?? []) { if (lifetime.ended) return; if (entry.environmentId !== environmentId) continue; - const thread = sourceThread(registry, environmentId, entry.threadId); - // The live shell is the server's state. It wins over a change made against an older one, - // which also covers a change that was sent before but whose reply was lost. - if ( - thread === undefined || - seenState(thread) !== entry.seen || - input.apply(thread, entry.action, DateTime.nowUnsafe()) === thread - ) { - await forget(registry, entry); - continue; - } // Marked before sending, so an Undo after a lost reply or a restart asks the server. const marked = await write(registry, (current) => current.some((item) => item.commandId === entry.commandId && item.attempted !== true) @@ -213,6 +210,19 @@ export function createThreadTriageOutbox(input: { // Storage no longer has it: cancelled, replaced, sent by another tab, or its environment // was removed. if (!marked.value.some((item) => item.commandId === entry.commandId)) continue; + // Checked after the last wait, so the shell judged is the one the send goes through. + if (!isLive(registry, environmentId)) return; + const thread = sourceThread(registry, environmentId, entry.threadId); + // The live shell is the server's state. It wins over a change made against an older one, + // which also covers a change that was sent before but whose reply was lost. + if ( + thread === undefined || + seenState(thread) !== entry.seen || + input.apply(thread, entry.action, DateTime.nowUnsafe()) === thread + ) { + await forget(registry, entry); + continue; + } const result = await input.send(registry, entry); if (lifetime.ended || (!AsyncResult.isSuccess(result) && isTransient(result.cause))) return; await forget(registry, entry); @@ -221,12 +231,9 @@ export function createThreadTriageOutbox(input: { const replayAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => { const entries = get(environmentEntriesAtom(environmentId)); - if ( - entries.length > 0 && - input.shellStatusAtom !== undefined && - get(input.shellStatusAtom(environmentId)) === "live" - ) + if (entries.length > 0 && get(input.shellStatusAtom(environmentId)) === "live") { void replay(get.registry, environmentId); + } }), ); @@ -278,23 +285,23 @@ export function createThreadTriageOutbox(input: { (reason) => Cause.isFailReason(reason) && isUnavailable(reason.error), ); if (!offline || snapshot == null || thread === undefined) return result; - await load(registry); const action = toAction(target.input); const queuedAt = DateTime.nowUnsafe(); - if ( - input.apply(thread, action, queuedAt) === thread || + if (input.apply(thread, action, queuedAt) === thread) return result; + let refused = false; + const queued = await write(registry, (stored, commandId) => { + const same = (entry: PendingThreadTriage) => + entry.environmentId === environmentId && entry.threadId === threadId; // One change per thread. A snooze cannot replace a settle, which online it keeps. - (action.type === "snooze" && - pendingFor(registry, environmentId, threadId)?.action.type === "settle") - ) - return result; - const queued = await write(registry, (stored, commandId) => [ - ...stored.filter( - (entry) => entry.environmentId !== environmentId || entry.threadId !== threadId, - ), - { environmentId, threadId, commandId, action, queuedAt, seen: seenState(thread) }, - ]); - return AsyncResult.isSuccess(queued) + refused = action.type === "snooze" && stored.find(same)?.action.type === "settle"; + return refused + ? stored + : [ + ...stored.filter((entry) => !same(entry)), + { environmentId, threadId, commandId, action, queuedAt, seen: seenState(thread) }, + ]; + }); + return AsyncResult.isSuccess(queued) && !refused ? AsyncResult.success({ sequence: snapshot.snapshotSequence }) : result; }, From 80d5595ce15b09ab0383232e9b8fc7714a58d4bf Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:29:27 +0000 Subject: [PATCH 39/44] fix(client-runtime): replay reads the stored queue and marks only while synchronized - Replay reads storage first, so a tab that loaded before another tab queued a change still sends it. - A change is marked as sent only while the shell is synchronized, so a disconnect during replay leaves later changes cancellable by Undo. Co-Authored-By: Claude Fable 5.2 --- .../src/state/threadCommands.test.ts | 30 +++++++++++++++---- .../src/state/threadTriageOutbox.ts | 18 +++++++---- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 35999c99ca60a..ee59143ef0312 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -744,17 +744,19 @@ describe("settle and snooze while the environment is offline", () => { "does not send once the shell stops being synchronized while the change is marked", () => Effect.gen(function* () { - const marking = yield* Queue.unbounded(); + const flipped = yield* Queue.unbounded(); const storage: { value: string | null; onModify?: () => void } = { value: null }; const h = yield* queued("settle", storage); - // A reconnect whose shell has not synchronized yet lands during the storage write. + let calls = 0; + // Replay reads the queue, then marks the change. A reconnect whose shell has not + // synchronized yet lands during the mark. storage.onModify = () => { + if (++calls !== 2) return; h.registry.set(h.statusAtom(ENVIRONMENT_ID), "synchronizing"); - Queue.offerUnsafe(marking, undefined); + Queue.offerUnsafe(flipped, undefined); }; yield* h.reconnect(); - yield* Queue.take(marking); - delete storage.onModify; + yield* Queue.take(flipped); // Undo reads storage after the mark, so its request can only follow a settle that was sent. const undone = h.commands.unsettle.run(h.registry, undo); const request = yield* Queue.take(h.requests); @@ -764,6 +766,24 @@ describe("settle and snooze while the environment is offline", () => { }), ); + it.effect("sends a change another tab queued after this tab loaded", () => + Effect.gen(function* () { + const storage = { value: null as string | null }; + const other = yield* makeHarness({ connected: false, storage }); + // Store calls run in order, so the other tab's first read is done after this one. + yield* other.store.update((entries) => entries); + yield* queued("settle", storage); + yield* other.reconnect(); + const request = yield* Queue.take(other.requests); + expect(request.command).toMatchObject({ + type: "thread.settle", + commandId: stored(storage)[0]!.commandId, + }); + yield* Deferred.succeed(request.reply, { sequence: 2 }); + yield* nothingPending(other); + }), + ); + it.effect("sends a queued change again when the first send got no answer", () => Effect.gen(function* () { const h = yield* queued(); diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index c2c164c74e27b..053b1be5b9c54 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -76,8 +76,9 @@ const sameEntries = (a: PendingThreadTriages, b: PendingThreadTriages) => /** * Settle and snooze for an environment that is not connected: the change is stored on the - * device, shown at once, and sent when the environment is back. Storage is the truth: memory - * mirrors what the last write returned, and a change is confirmed there right before it is sent. + * device, shown at once, and sent when the environment is back. Storage is the truth, so tabs + * sharing it agree: memory mirrors what the last read or write returned, and a change is + * confirmed there right before it is sent. */ export function createThreadTriageOutbox(input: { readonly runtime: Atom.AtomRuntime; @@ -195,9 +196,13 @@ export function createThreadTriageOutbox(input: { environmentId: EnvironmentId, lifetime: { readonly ended: boolean }, ) => { - for (const entry of registry.get(entriesAtom) ?? []) { - if (lifetime.ended) return; + // Read again: another tab may have queued a change this one has not seen. + const stored = await write(registry); + if (!AsyncResult.isSuccess(stored)) return; + for (const entry of stored.value) { if (entry.environmentId !== environmentId) continue; + // A change is only marked as sent while it can be sent, so Undo can still cancel the rest. + if (lifetime.ended || !isLive(registry, environmentId)) return; // Marked before sending, so an Undo after a lost reply or a restart asks the server. const marked = await write(registry, (current) => current.some((item) => item.commandId === entry.commandId && item.attempted !== true) @@ -230,8 +235,9 @@ export function createThreadTriageOutbox(input: { }; const replayAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => { - const entries = get(environmentEntriesAtom(environmentId)); - if (entries.length > 0 && get(input.shellStatusAtom(environmentId)) === "live") { + // Also runs when this tab's queue changes while the shell is synchronized. + get(environmentEntriesAtom(environmentId)); + if (get(input.shellStatusAtom(environmentId)) === "live") { void replay(get.registry, environmentId); } }), From b2f589cfc18ad8303d58286b5fb2c9484f68a2d8 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:40:19 +0000 Subject: [PATCH 40/44] fix(client-runtime): a change whose send never left the device stays cancellable A not-connected failure on this path is raised before the request is sent, so the sent mark made in that pass is taken off and Undo can cancel the change on the device again. The mark no longer wakes replay. Co-Authored-By: Claude Fable 5.2 --- .../src/state/threadCommands.test.ts | 19 +++++++++++++ .../src/state/threadTriageOutbox.ts | 27 ++++++++++++------- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index ee59143ef0312..a986eb3ab8667 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -766,6 +766,25 @@ describe("settle and snooze while the environment is offline", () => { }), ); + it.effect("keeps a change cancellable when its send never left the device", () => + Effect.gen(function* () { + const unmarked = yield* Queue.unbounded(); + const storage: { value: string | null; onModify?: () => void } = { value: null }; + const h = yield* queued("settle", storage); + let calls = 0; + // Replay reads the queue, marks the change, and takes the mark off again. + storage.onModify = () => { + if (++calls === 3) Queue.offerUnsafe(unmarked, undefined); + }; + // The shell reports synchronized from a stale stream while the session is already gone. + h.registry.set(h.statusAtom(ENVIRONMENT_ID), "live"); + yield* Queue.take(unmarked); + const result = yield* Effect.promise(() => h.commands.unsettle.run(h.registry, undo)); + expect(result._tag === "Success" && ThreadUndo.cancelledPendingSync(result.value)).toBe(true); + expect(stored(storage)).toEqual([]); + }), + ); + it.effect("sends a change another tab queued after this tab loaded", () => Effect.gen(function* () { const storage = { value: null as string | null }; diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index 053b1be5b9c54..01c2a2e6cf91f 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -67,12 +67,13 @@ const without = (entries: PendingThreadTriages, commandId: CommandId) => ? entries.filter((entry) => entry.commandId !== commandId) : entries; +/** The sent mark is left out: it is only ever read from storage. */ const sameEntries = (a: PendingThreadTriages, b: PendingThreadTriages) => - a.length === b.length && - a.every( - (entry, index) => - entry.commandId === b[index]!.commandId && entry.attempted === b[index]!.attempted, - ); + a.length === b.length && a.every((entry, index) => entry.commandId === b[index]!.commandId); + +/** Raised before a request is sent, when the environment has no session. */ +const isOffline = (cause: Cause.Cause) => + cause.reasons.some((reason) => Cause.isFailReason(reason) && isUnavailable(reason.error)); /** * Settle and snooze for an environment that is not connected: the change is stored on the @@ -229,7 +230,16 @@ export function createThreadTriageOutbox(input: { continue; } const result = await input.send(registry, entry); - if (lifetime.ended || (!AsyncResult.isSuccess(result) && isTransient(result.cause))) return; + if (lifetime.ended) return; + if (!AsyncResult.isSuccess(result) && isTransient(result.cause)) { + // It never left the device, so the mark this pass made comes off and Undo can cancel it. + if (isOffline(result.cause) && entry.attempted !== true) { + await write(registry, (current) => + current.map((item) => (item.commandId === entry.commandId ? entry : item)), + ); + } + return; + } await forget(registry, entry); } }; @@ -287,10 +297,7 @@ export function createThreadTriageOutbox(input: { if (previous !== undefined) await forget(registry, previous); return result; } - const offline = result.cause.reasons.some( - (reason) => Cause.isFailReason(reason) && isUnavailable(reason.error), - ); - if (!offline || snapshot == null || thread === undefined) return result; + if (!isOffline(result.cause) || snapshot == null || thread === undefined) return result; const action = toAction(target.input); const queuedAt = DateTime.nowUnsafe(); if (input.apply(thread, action, queuedAt) === thread) return result; From 37d7698f497d05b9fb3e98b8e990c985805cfa28 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:47:25 +0000 Subject: [PATCH 41/44] fix(client-runtime): a disconnect while a change is marked leaves it cancellable Replay takes off the mark it made whenever the change provably was not sent: when the shell stopped being synchronized during the marking write, as well as when the send found no session. Co-Authored-By: Claude Fable 5.2 --- .../src/state/threadCommands.test.ts | 45 +++++++------------ .../src/state/threadTriageOutbox.ts | 17 +++---- 2 files changed, 24 insertions(+), 38 deletions(-) diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index a986eb3ab8667..e7f9538298306 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -740,33 +740,12 @@ describe("settle and snooze while the environment is offline", () => { }), ); - it.effect( - "does not send once the shell stops being synchronized while the change is marked", - () => - Effect.gen(function* () { - const flipped = yield* Queue.unbounded(); - const storage: { value: string | null; onModify?: () => void } = { value: null }; - const h = yield* queued("settle", storage); - let calls = 0; - // Replay reads the queue, then marks the change. A reconnect whose shell has not - // synchronized yet lands during the mark. - storage.onModify = () => { - if (++calls !== 2) return; - h.registry.set(h.statusAtom(ENVIRONMENT_ID), "synchronizing"); - Queue.offerUnsafe(flipped, undefined); - }; - yield* h.reconnect(); - yield* Queue.take(flipped); - // Undo reads storage after the mark, so its request can only follow a settle that was sent. - const undone = h.commands.unsettle.run(h.registry, undo); - const request = yield* Queue.take(h.requests); - expect(request.command.type).toBe("thread.unsettle"); - yield* Deferred.succeed(request.reply, { sequence: 2 }); - expect((yield* Effect.promise(() => undone))._tag).toBe("Success"); - }), - ); - - it.effect("keeps a change cancellable when its send never left the device", () => + it.effect.each([ + // A reconnect whose shell has not synchronized yet lands while the change is being marked. + { when: "the shell stops being synchronized while it is marked", connected: true }, + // The shell reports synchronized from a stale stream while the session is already gone. + { when: "its environment has no session", connected: false }, + ])("leaves a change unsent and cancellable when $when", ({ connected }) => Effect.gen(function* () { const unmarked = yield* Queue.unbounded(); const storage: { value: string | null; onModify?: () => void } = { value: null }; @@ -774,14 +753,20 @@ describe("settle and snooze while the environment is offline", () => { let calls = 0; // Replay reads the queue, marks the change, and takes the mark off again. storage.onModify = () => { - if (++calls === 3) Queue.offerUnsafe(unmarked, undefined); + calls += 1; + if (calls === 2 && connected) { + h.registry.set(h.statusAtom(ENVIRONMENT_ID), "synchronizing"); + } + if (calls === 3) Queue.offerUnsafe(unmarked, undefined); }; - // The shell reports synchronized from a stale stream while the session is already gone. - h.registry.set(h.statusAtom(ENVIRONMENT_ID), "live"); + if (connected) yield* h.reconnect(); + else h.registry.set(h.statusAtom(ENVIRONMENT_ID), "live"); yield* Queue.take(unmarked); const result = yield* Effect.promise(() => h.commands.unsettle.run(h.registry, undo)); expect(result._tag === "Success" && ThreadUndo.cancelledPendingSync(result.value)).toBe(true); expect(stored(storage)).toEqual([]); + yield* h.reconnect(); + yield* expectNothingSent(h); }), ); diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index 01c2a2e6cf91f..5885dff5def68 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -216,8 +216,15 @@ export function createThreadTriageOutbox(input: { // Storage no longer has it: cancelled, replaced, sent by another tab, or its environment // was removed. if (!marked.value.some((item) => item.commandId === entry.commandId)) continue; + // A change that never left the device loses the mark this pass made, so Undo can cancel it. + const unmark = async () => { + if (entry.attempted === true) return; + await write(registry, (current) => + current.map((item) => (item.commandId === entry.commandId ? entry : item)), + ); + }; // Checked after the last wait, so the shell judged is the one the send goes through. - if (!isLive(registry, environmentId)) return; + if (!isLive(registry, environmentId)) return unmark(); const thread = sourceThread(registry, environmentId, entry.threadId); // The live shell is the server's state. It wins over a change made against an older one, // which also covers a change that was sent before but whose reply was lost. @@ -232,13 +239,7 @@ export function createThreadTriageOutbox(input: { const result = await input.send(registry, entry); if (lifetime.ended) return; if (!AsyncResult.isSuccess(result) && isTransient(result.cause)) { - // It never left the device, so the mark this pass made comes off and Undo can cancel it. - if (isOffline(result.cause) && entry.attempted !== true) { - await write(registry, (current) => - current.map((item) => (item.commandId === entry.commandId ? entry : item)), - ); - } - return; + return isOffline(result.cause) ? unmark() : undefined; } await forget(registry, entry); } From 285a9bd1e25ae1627aa544db95e2efc53aeac232 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:55:18 +0000 Subject: [PATCH 42/44] fix(client-runtime): replay only takes off a sent mark it made itself A mark another tab or an earlier pass made stays when this pass could not send, because that send may have reached the server. Co-Authored-By: Claude Fable 5.2 --- .../src/state/threadCommands.test.ts | 24 +++++++++++++++++++ .../src/state/threadTriageOutbox.ts | 23 ++++++++++++------ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index e7f9538298306..3515b32ba9255 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -770,6 +770,30 @@ describe("settle and snooze while the environment is offline", () => { }), ); + it.effect("keeps another tab's sent mark when this tab could not send", () => + Effect.gen(function* () { + const flipped = yield* Queue.unbounded(); + const storage: { value: string | null; onModify?: () => void } = { value: null }; + const h = yield* queued("settle", storage); + let calls = 0; + // Between this tab's read and its mark, another tab marks the change and sends it. Then + // this tab's shell stops being synchronized. + storage.onModify = () => { + if (++calls !== 2) return; + storage.value = storage.value!.replace('"seen"', '"attempted":true,"seen"'); + h.registry.set(h.statusAtom(ENVIRONMENT_ID), "synchronizing"); + Queue.offerUnsafe(flipped, undefined); + }; + yield* h.reconnect(); + yield* Queue.take(flipped); + const undone = h.commands.unsettle.run(h.registry, undo); + const request = yield* Queue.take(h.requests); + expect(request.command.type).toBe("thread.unsettle"); + yield* Deferred.succeed(request.reply, { sequence: 2 }); + expect((yield* Effect.promise(() => undone))._tag).toBe("Success"); + }), + ); + it.effect("sends a change another tab queued after this tab loaded", () => Effect.gen(function* () { const storage = { value: null as string | null }; diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index 5885dff5def68..a9eb698e21277 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -205,22 +205,31 @@ export function createThreadTriageOutbox(input: { // A change is only marked as sent while it can be sent, so Undo can still cancel the rest. if (lifetime.ended || !isLive(registry, environmentId)) return; // Marked before sending, so an Undo after a lost reply or a restart asks the server. - const marked = await write(registry, (current) => - current.some((item) => item.commandId === entry.commandId && item.attempted !== true) + let markedHere = false; + const marked = await write(registry, (current) => { + markedHere = current.some( + (item) => item.commandId === entry.commandId && item.attempted !== true, + ); + return markedHere ? current.map((item) => item.commandId === entry.commandId ? { ...item, attempted: true as const } : item, ) - : current, - ); + : current; + }); if (lifetime.ended || !AsyncResult.isSuccess(marked)) return; // Storage no longer has it: cancelled, replaced, sent by another tab, or its environment // was removed. if (!marked.value.some((item) => item.commandId === entry.commandId)) continue; - // A change that never left the device loses the mark this pass made, so Undo can cancel it. + // A change that never left the device loses the mark this pass made, so Undo can cancel + // it. A mark from an earlier pass or another tab stays: that send may have arrived. const unmark = async () => { - if (entry.attempted === true) return; + if (!markedHere) return; await write(registry, (current) => - current.map((item) => (item.commandId === entry.commandId ? entry : item)), + current.map(({ attempted, ...item }) => + item.commandId === entry.commandId || attempted !== true + ? item + : { ...item, attempted }, + ), ); }; // Checked after the last wait, so the shell judged is the one the send goes through. From 5bc9fdaacd8d0bfb52bde978c8e92772942ccb79 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 05:05:41 +0000 Subject: [PATCH 43/44] fix(client-runtime): retry reading the offline queue after a failed first read Co-Authored-By: Claude Fable 5.2 --- packages/client-runtime/src/state/threadTriageOutbox.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/threadTriageOutbox.ts b/packages/client-runtime/src/state/threadTriageOutbox.ts index a9eb698e21277..7d035f94f560b 100644 --- a/packages/client-runtime/src/state/threadTriageOutbox.ts +++ b/packages/client-runtime/src/state/threadTriageOutbox.ts @@ -147,7 +147,11 @@ export function createThreadTriageOutbox(input: { write(registry, (stored) => without(stored, entry.commandId)); let loading: Promise | undefined; - const load = (registry: AtomRegistry.AtomRegistry) => (loading ??= write(registry)); + const load = (registry: AtomRegistry.AtomRegistry) => + (loading ??= write(registry).then((result) => { + // A failed read is tried again the next time the queue is looked at. + if (!AsyncResult.isSuccess(result)) loading = undefined; + })); const environmentEntriesAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => { const entries = get(entriesAtom); From 69225049058af001891391a5b0b272f53801436d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 07:18:53 +0000 Subject: [PATCH 44/44] test(client-runtime): drop offline triage cases another case already covers Co-Authored-By: Claude Opus 5.5 --- .../src/state/threadCommands.test.ts | 42 +++---------------- 1 file changed, 5 insertions(+), 37 deletions(-) diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 5d49accd488ce..b08a9fde70cd1 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -831,10 +831,7 @@ describe("settle and snooze while the environment is offline", () => { it.effect.each([ ["produced new output", { itemCount: 1 }], - ["started a run", { latestRunId: RunId.make("later-run") }], ["was pinned on another device", { pinnedAt: NOW }], - ["was settled and un-settled on another device", { unsettledAt: NOW }], - ["was settled on another device", { settledOverride: "settled" as const, settledAt: NOW }], ["is asking for approval", { pendingRuntimeRequest: APPROVAL }], ] as const)("keeps the server's state for a thread that %s meanwhile", ([, change]) => Effect.gen(function* () { @@ -905,24 +902,6 @@ describe("settle and snooze while the environment is offline", () => { }), ); - it.effect("sends a change another tab queued after this tab loaded", () => - Effect.gen(function* () { - const storage = { value: null as string | null }; - const other = yield* makeHarness({ connected: false, storage }); - // Store calls run in order, so the other tab's first read is done after this one. - yield* other.store.update((entries) => entries); - yield* queued("settle", storage); - yield* other.reconnect(); - const request = yield* Queue.take(other.requests); - expect(request.command).toMatchObject({ - type: "thread.settle", - commandId: stored(storage)[0]!.commandId, - }); - yield* Deferred.succeed(request.reply, { sequence: 2 }); - yield* nothingPending(other); - }), - ); - it.effect("sends a queued change again when the first send got no answer", () => Effect.gen(function* () { const h = yield* queued(); @@ -1047,16 +1026,6 @@ describe("settle and snooze while the environment is offline", () => { }), ); - it.effect("forgets a removed environment's queued changes", () => - Effect.gen(function* () { - const h = yield* queued(); - yield* h.store.clearEnvironment(ENVIRONMENT_ID); - yield* h.reconnect(); - yield* nothingPending(h); - yield* expectNothingSent(h); - }), - ); - it.effect("does not queue a settle the cached thread cannot take", () => Effect.gen(function* () { const h = yield* makeHarness({ connected: false }); @@ -1068,19 +1037,18 @@ describe("settle and snooze while the environment is offline", () => { ); // The other tab loaded before the settle was queued, so only storage knows about it. - it.effect.each(["this tab", "another tab"] as const)( - "keeps a settle queued in %s when a snooze of the same thread would replace it", - (tab) => + it.effect( + "keeps a settle queued in another tab when a snooze of the same thread would replace it", + () => Effect.gen(function* () { const storage = { value: null as string | null }; const other = yield* makeHarness({ connected: false, storage }); // Store calls run in order, so the other tab's first read is done after this one. yield* other.store.update((stored) => stored); - const h = yield* queued("settle", storage); + yield* queued("settle", storage); const before = storage.value; - const snoozing = tab === "this tab" ? h : other; const result = yield* Effect.promise(() => - snoozing.commands.snooze.run(snoozing.registry, snooze), + other.commands.snooze.run(other.registry, snooze), ); expect(result._tag).toBe("Failure"); expect(storage.value).toBe(before);