diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index f7db3c277810..18813eb9025a 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -357,6 +357,34 @@ describe("DesktopSettings", () => { ), ); + it.effect("saves through a symlinked settings file without replacing the link", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const dotfiles = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-settings-dotfiles-", + }); + const linkedSettingsPath = `${dotfiles}/desktop-settings.json`; + yield* fileSystem.writeFileString(linkedSettingsPath, "{}\n"); + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.symlink(linkedSettingsPath, environment.desktopSettingsPath); + + yield* settings.setServerExposureMode("network-accessible"); + + assert.equal( + yield* fileSystem.readLink(environment.desktopSettingsPath), + linkedSettingsPath, + ); + const persisted = yield* decodeDesktopSettingsPatch( + yield* fileSystem.readFileString(linkedSettingsPath), + ); + assert.equal(persisted.serverExposureMode, "network-accessible"); + }), + ), + ); + it.effect("migrates legacy implicit update channels to the runtime default", () => withSettings( Effect.gen(function* () { diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 19fcf0e75962..efd9fbfbfd3b 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -5,6 +5,7 @@ import { type DesktopUpdateChannel, } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -130,6 +131,7 @@ const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSet const DesktopSettingsWriteOperation = Schema.Literals([ "create-temporary-file-name", + "resolve-symlink", "encode-document", "create-directory", "write-temporary-file", @@ -421,8 +423,20 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp readonly defaultSettings: DesktopSettings; readonly suffix: string; }): Effect.fn.Return { - const directory = input.path.dirname(input.settingsPath); - const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; + const targetPath = yield* resolveSymlinkTarget(input.settingsPath).pipe( + Effect.provideService(FileSystem.FileSystem, input.fileSystem), + Effect.provideService(Path.Path, input.path), + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "resolve-symlink", + path: input.settingsPath, + cause, + }), + ), + ); + const directory = input.path.dirname(targetPath); + const tempPath = `${targetPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeDesktopSettingsJson( toDesktopSettingsDocument(input.settings, input.defaultSettings), ).pipe( @@ -455,7 +469,7 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp }), ), ); - yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe( + yield* input.fileSystem.rename(tempPath, targetPath).pipe( Effect.mapError( (cause) => new DesktopSettingsWriteError({ diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 94fe587136ef..2d88fb80a670 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -149,6 +149,34 @@ describe("DesktopClientSettings", () => { ), ); + it.effect("saves through a symlinked client settings file without replacing the link", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + const dotfiles = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-client-settings-dotfiles-", + }); + const linkedSettingsPath = `${dotfiles}/client-settings.json`; + yield* fileSystem.writeFileString(linkedSettingsPath, "{}\n"); + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.symlink(linkedSettingsPath, environment.clientSettingsPath); + + yield* settings.set(clientSettings); + + assert.equal( + yield* fileSystem.readLink(environment.clientSettingsPath), + linkedSettingsPath, + ); + assert.deepEqual( + yield* decodeClientSettingsJson(yield* fileSystem.readFileString(linkedSettingsPath)), + clientSettings, + ); + }), + ), + ); + it.effect.each([ { label: "permission", reason: "PermissionDenied" }, { label: "I/O", reason: "Unknown" }, diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index cd7fea6ca116..50b1e09aacee 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -1,5 +1,6 @@ import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -41,6 +42,7 @@ export class DesktopClientSettingsReadError extends Schema.TaggedError { - const directory = input.path.dirname(input.settingsPath); - const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; + const targetPath = yield* resolveSymlinkTarget(input.settingsPath).pipe( + Effect.provideService(FileSystem.FileSystem, input.fileSystem), + Effect.provideService(Path.Path, input.path), + Effect.mapError( + (cause) => + new DesktopClientSettingsWriteError({ + operation: "resolve-symlink", + path: input.settingsPath, + cause, + }), + ), + ); + const directory = input.path.dirname(targetPath); + const tempPath = `${targetPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeClientSettingsJson(input.settings).pipe( Effect.mapError( (cause) => @@ -158,7 +172,7 @@ const writeClientSettings = Effect.fnUntraced(function* (input: { }), ), ); - yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe( + yield* input.fileSystem.rename(tempPath, targetPath).pipe( Effect.mapError( (cause) => new DesktopClientSettingsWriteError({ diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index b485413a4c2c..037a821ded8b 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -418,7 +418,16 @@ const config: ExpoConfig = { minSdkVersion: 24, // kotlinx-io uses Kotlin 2.3's return-value checker annotation, while // SDK 58 builds with Kotlin 2.2. It has no runtime behavior. - extraProguardRules: "-dontwarn kotlin.MustUseReturnValues", + // + // WorkManager 2.9 keeps InputMerger classes but not their constructors, + // and R8 full mode no longer keeps a default constructor implicitly. + // Without it no work request can start, so the Glance session behind + // the widget never renders and it stays on "Loading widget". WorkManager + // 2.10 ships this rule itself; drop it once the resolved version gets there. + extraProguardRules: [ + "-dontwarn kotlin.MustUseReturnValues", + "-keep class * extends androidx.work.InputMerger { (); }", + ].join("\n"), }, ios: { deploymentTarget: "18.0", diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index aab366710176..6538697da297 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -44,6 +44,7 @@ import { ThreadListV2SettledShelfHeader, ThreadListV2ShowMoreRow, ThreadListV2SnoozedShelfHeader, + ThreadListV2WorkingShelfHeader, } from "../threads/thread-list-v2-items"; import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance"; import { @@ -51,6 +52,7 @@ import { getThreadListV2OrderedSection, buildThreadListV2ListItems, threadListV2ListItemsAreEqual, + threadListInboxReturns, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, @@ -466,8 +468,11 @@ export function HomeScreen(props: HomeScreenProps) { loaded: shelfPreferencesLoaded, settledShelfExpanded, snoozedShelfExpanded, + workingShelfEnabled, + workingShelfExpanded, toggleSettledShelf, toggleSnoozedShelf, + toggleWorkingShelf, } = useThreadListV2ShelfPreferences(); // The queued-start and snooze helpers need a clock while the list stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); @@ -520,8 +525,13 @@ export function HomeScreen(props: HomeScreenProps) { queuedThreadKeys, }), }); - return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]); + // The Working beta orders the inbox by time, so only pins can move. + return new Map([ + ...sectionAvailability("pinned"), + ...(workingShelfEnabled ? [] : sectionAvailability("active")), + ]); }, [ + workingShelfEnabled, pinReorderEnvironmentIds, activeReorderEnvironmentIds, props.threads, @@ -533,6 +543,7 @@ export function HomeScreen(props: HomeScreenProps) { snoozeWakeTick, ]); const threadListV2Layout = useMemo(() => { + threadListInboxReturns.observe(workingShelfEnabled ? props.threads : null); // Settled threads are live shells; archived threads keep their original // "hidden from lists" meaning. return buildThreadListV2Items({ @@ -547,11 +558,16 @@ export function HomeScreen(props: HomeScreenProps) { queuedThreadKeys, settledLimit: settledVisibleCount, now: new Date().toISOString(), + workingShelfEnabled, + workingShelfExpanded, + inboxReturnAt: threadListInboxReturns.returnedAt, snoozedShelfExpanded, settledShelfExpanded, selectedThreadKey: null, }); }, [ + workingShelfEnabled, + workingShelfExpanded, pendingOrder, queuedThreadKeys, nowMinute, @@ -606,6 +622,9 @@ export function HomeScreen(props: HomeScreenProps) { buildThreadListV2ListItems({ items: threadListV2Layout.items, pendingTasks: v2PendingTasks, + workingCount: threadListV2Layout.workingCount, + workingShelfExpanded, + workingShelfHeaderIndex: threadListV2Layout.workingShelfHeaderIndex, snoozedCount: threadListV2Layout.snoozedCount, snoozedShelfExpanded, snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, @@ -628,6 +647,7 @@ export function HomeScreen(props: HomeScreenProps) { snoozeEnvironmentIds, threadListV2Layout, v2PendingTasks, + workingShelfExpanded, ], ); @@ -662,6 +682,16 @@ export function HomeScreen(props: HomeScreenProps) { /> ); } + if (item.type === "v2-working-shelf") { + return ( + + ); + } if (item.type === "v2-snoozed-shelf") { return ( item.key, []); @@ -800,6 +832,8 @@ export function HomeScreen(props: HomeScreenProps) { savedConnectionsById: props.savedConnectionsById, searchQuery: props.searchQuery, threadSearchMatchByKey, + // Rows read it for their reorder menu items. + workingShelfEnabled, }), [ projectByKey, @@ -808,6 +842,7 @@ export function HomeScreen(props: HomeScreenProps) { listEnvironments, threadSearchMatchByKey, v2ProjectTitleByProjectKey, + workingShelfEnabled, ], ); diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx index 5ff83dea728f..dab724e236a3 100644 --- a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx @@ -45,6 +45,7 @@ export function SettingsThreadsRouteScreen() { contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} > + @@ -239,6 +240,35 @@ function AutoSettleSettingsRows() { ); } +/** + * Device-local beta toggles, the counterpart of web's Working section (beta) + * in Settings → General. + */ +function BetaSettingsSection() { + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const preferences = useAtomValue(mobilePreferencesAtom); + const workingShelfEnabled = + AsyncResult.isSuccess(preferences) && preferences.value.workingShelfEnabled === true; + + return ( + + + savePreferences({ workingShelfEnabled: value })} + /> + + + Fold working and monitoring threads into a Working section. They return to the top of the + list when they need you. While this is on, active threads are ordered by time and cannot be + moved. + + + ); +} + /** * Device-local legacy toggles. Mobile has no client-settings sync, so this is * the counterpart of web's Settings → General → Legacy features backed by diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx index cb25aa67af78..97dc6cf85eca 100644 --- a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx @@ -2,6 +2,7 @@ import { appAtomRegistry } from "../../state/atom-registry"; import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { sortInboxThreadsByReturn } from "@t3tools/client-runtime/state/thread-inbox"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { Animated, FlatList, Modal, Pressable, View } from "react-native"; import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; @@ -26,7 +27,8 @@ import { threadDragAction, type ThreadMoveDestination, } from "./threadOrder"; -import { getThreadListV2OrderedSection } from "./threadListV2"; +import { getThreadListV2OrderedSection, threadListInboxReturns } from "./threadListV2"; +import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; const ROW_HEIGHT = 56; const HEADER_HEIGHT = 48; @@ -154,6 +156,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { const pendingOrder = useAtomValue(pendingThreadOrderAtom); const dropBusy = useAtomValue(threadDropBusyAtom); const { moveThread } = useThreadListActions(); + const { workingShelfEnabled } = useThreadListV2ShelfPreferences(); const [now, setNow] = useState(() => new Date().toISOString()); const [expanded, setExpanded] = useState({ snoozed: false, settled: false }); useEffect(() => { @@ -195,23 +198,28 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { ); return { pinned, - active, + // The Working beta orders the inbox by time; show that order here too. + active: workingShelfEnabled + ? sortInboxThreadsByReturn(active, threadListInboxReturns.returnedAt) + : active, snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })), settled: parked.filter((thread) => !effectiveSnoozed(thread, { now })), }; - }, [threads, configs, now, queuedThreadKeys, pendingOrder]); + }, [threads, configs, now, queuedThreadKeys, pendingOrder, workingShelfEnabled]); const planners = useMemo(() => { const planner = (section: "pinned" | "active") => createThreadMovePlanner({ ordered: sections[section], allThreads: threads, section, + // A time-ordered inbox has no slots, so Active takes no drops while + // the Working beta is on. The saved arrangement stays untouched. reorderableEnvironmentIds: new Set( [...configs].flatMap(([id, config]) => ( section === "pinned" ? config.environment.capabilities.threadPinReorder - : config.environment.capabilities.threadActiveReorder + : !workingShelfEnabled && config.environment.capabilities.threadActiveReorder ) ? [id] : [], @@ -219,7 +227,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { ), }); return { pinned: planner("pinned"), active: planner("active") }; - }, [sections, threads, configs]); + }, [sections, threads, configs, workingShelfEnabled]); const rows = useMemo(() => { const result: Row[] = []; let offset = 0; diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index d60425a6af72..27d23b0a0b15 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -61,6 +61,7 @@ import { ThreadListV2SettledShelfHeader, ThreadListV2ShowMoreRow, ThreadListV2SnoozedShelfHeader, + ThreadListV2WorkingShelfHeader, } from "./thread-list-v2-items"; import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance"; import { @@ -69,6 +70,7 @@ import { buildThreadListV2ListItems, isThreadListV2ListItem, threadListV2ListItemsAreEqual, + threadListInboxReturns, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, @@ -291,8 +293,11 @@ function ThreadNavigationSidebarPane( loaded: shelfPreferencesLoaded, settledShelfExpanded, snoozedShelfExpanded, + workingShelfEnabled, + workingShelfExpanded, toggleSettledShelf, toggleSnoozedShelf, + toggleWorkingShelf, } = useThreadListV2ShelfPreferences(); // The queued-start and snooze helpers need a clock while the pane stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); @@ -343,8 +348,13 @@ function ThreadNavigationSidebarPane( queuedThreadKeys, }), }); - return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]); + // The Working beta orders the inbox by time, so only pins can move. + return new Map([ + ...sectionAvailability("pinned"), + ...(workingShelfEnabled ? [] : sectionAvailability("active")), + ]); }, [ + workingShelfEnabled, pinReorderEnvironmentIds, activeReorderEnvironmentIds, threads, @@ -356,6 +366,7 @@ function ThreadNavigationSidebarPane( snoozeWakeTick, ]); const threadListV2Layout = useMemo(() => { + threadListInboxReturns.observe(workingShelfEnabled ? threads : null); return buildThreadListV2Items({ pendingOrder, threads: threads.filter((thread) => thread.archivedAt === null), @@ -368,11 +379,16 @@ function ThreadNavigationSidebarPane( queuedThreadKeys, settledLimit: settledVisibleCount, now: new Date().toISOString(), + workingShelfEnabled, + workingShelfExpanded, + inboxReturnAt: threadListInboxReturns.returnedAt, snoozedShelfExpanded, settledShelfExpanded, selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ + workingShelfEnabled, + workingShelfExpanded, pendingOrder, queuedThreadKeys, nowMinute, @@ -424,6 +440,9 @@ function ThreadNavigationSidebarPane( const items: SidebarListItem[] = buildThreadListV2ListItems({ items: threadListV2Layout.items, pendingTasks: v2PendingTasks, + workingCount: threadListV2Layout.workingCount, + workingShelfExpanded, + workingShelfHeaderIndex: threadListV2Layout.workingShelfHeaderIndex, snoozedCount: threadListV2Layout.snoozedCount, snoozedShelfExpanded, snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, @@ -457,6 +476,7 @@ function ThreadNavigationSidebarPane( snoozedShelfExpanded, snoozeEnvironmentIds, threadListV2Layout, + workingShelfExpanded, ]); const listMenuActions = useMemo( () => [ @@ -587,6 +607,8 @@ function ThreadNavigationSidebarPane( savedConnectionsById, listEnvironments, threadSearchMatchByKey, + // Rows read it for their reorder menu items. + workingShelfEnabled, }), [ props.selectedThreadKey, @@ -595,6 +617,7 @@ function ThreadNavigationSidebarPane( savedConnectionsById, listEnvironments, threadSearchMatchByKey, + workingShelfEnabled, ], ); useThreadJumpShortcuts(listItems, handleSelectThread); @@ -712,7 +735,7 @@ function ThreadNavigationSidebarPane( reorderSupported={ item.item.pinned ? pinReorderEnvironmentIds.has(thread.environmentId) - : activeReorderEnvironmentIds.has(thread.environmentId) + : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId) } canMoveUp={item.canMoveUp} canMoveDown={item.canMoveDown} @@ -729,6 +752,16 @@ function ThreadNavigationSidebarPane( /> ); } + case "v2-working-shelf": + return ( + + ); case "v2-snoozed-shelf": return ( ; +}); + export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader( props: ThreadListV2ShelfHeaderProps, ) { diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 2f06b9462b4d..57287c81eda1 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -41,7 +41,17 @@ import { View, } from "react-native"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; -import type { EnvironmentId, ToolActivityIcon } from "@t3tools/contracts"; +import { + ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, + type EnvironmentId, + type RunId, + type ThreadId, + type ToolActivityIcon, +} from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { environmentThreadDetails, threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; import { AppText as Text } from "../../components/AppText"; @@ -770,6 +780,44 @@ function workLogRowKey(row: ThreadFeedActivity): string { return row.id; } +/** Shown while the thread's run still ends in this failed preparation. */ +function WorkspacePreparationRetryButton(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly runId: RunId; +}) { + const retryable = useAtomValue( + environmentThreadDetails.threadAtom(scopeThreadRef(props.environmentId, props.threadId)), + (thread) => { + const run = thread?.projection.runs.find((candidate) => candidate.id === props.runId); + return run?.status === "failed" && run.workspacePreparation !== undefined; + }, + ); + const retry = useAtomCommand(threadEnvironment.retryWorkspacePreparation, "retry setup"); + const [busy, setBusy] = useState(false); + if (!retryable) return null; + return ( + { + setBusy(true); + void Haptics.selectionAsync(); + void retry({ + environmentId: props.environmentId, + input: { threadId: props.threadId, runId: props.runId }, + }).finally(() => setBusy(false)); + }} + className="ml-7 mt-2 min-h-11 flex-row items-center gap-1.5 self-start rounded-full border border-border px-4" + style={{ opacity: busy ? 0.5 : 1 }} + > + + Retry + + ); +} + const ThreadWorkLogRow = memo(function ThreadWorkLogRow( props: Omit< ThreadWorkLogProps, @@ -850,6 +898,14 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( {failureItem.failure.message} ) : null} + {failureItem.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE && + failureItem.runId !== null ? ( + + ) : null} ); @@ -1009,7 +1065,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( entering={WORK_LOG_DETAIL_ENTER_TRANSITION} exiting={WORK_LOG_DETAIL_EXIT_TRANSITION} layout={WORK_LOG_LAYOUT_TRANSITION} - className={reasoning ? "ml-7 py-1" : "ml-7 border-l border-border pb-1 pl-3 pt-0.5"} + className={reasoning ? "ml-7 py-1" : "pb-1 pt-0.5"} > {row.workEntry.questionAnswer ? ( { expect(threadListV2ListItemsAreEqual(shelfLoading, shelfLoaded)).toBe(false); }); }); + +describe("Working section beta", () => { + const running = { + status: "running" as const, + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: null, + updatedAt: NOW, + }; + const finishedAt = (id: string, completedAt: string) => + makeThread({ + id: ThreadId.make(id), + title: id, + createdAt: "2026-06-01T00:00:00.000Z", + latestRun: { + runId: RunId.make(`run-${id}`), + status: "completed", + requestedAt: "2026-06-01T00:00:00.000Z", + startedAt: "2026-06-01T00:00:00.000Z", + completedAt, + assistantMessageId: null, + }, + }); + const threads = [ + finishedAt("finished-early", "2026-06-01T01:00:00.000Z"), + finishedAt("finished-late", "2026-06-01T03:00:00.000Z"), + makeThread({ id: ThreadId.make("working"), title: "working", runtime: running }), + makeThread({ + id: ThreadId.make("asks-approval"), + title: "asks-approval", + createdAt: "2026-06-01T02:00:00.000Z", + runtime: running, + hasPendingApprovals: true, + }), + makeThread({ + id: ThreadId.make("pinned-working"), + title: "pinned-working", + runtime: running, + pinnedAt: "2026-06-01T00:00:00.000Z", + }), + ]; + const build = (input: Partial[0]> = {}) => + buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "", + now: NOW, + workingShelfEnabled: true, + ...input, + }); + const ids = (layout: ReturnType) => + layout.items.map((item) => item.thread.id); + + it("folds unpinned working threads into a collapsed shelf", () => { + const layout = build(); + expect(ids(layout)).toEqual([ + "pinned-working", + "finished-late", + "asks-approval", + "finished-early", + ]); + expect(layout.workingCount).toBe(1); + expect(layout.workingShelfHeaderIndex).toBe(4); + + const off = build({ workingShelfEnabled: false }); + expect(ids(off)).toContain("working"); + expect(off.workingCount).toBe(0); + }); + + it("shows working rows as cards when expanded, or only the selected one when collapsed", () => { + expect(build({ workingShelfExpanded: true }).items.at(-1)).toMatchObject({ + thread: { id: "working" }, + variant: "card", + }); + expect(ids(build({ selectedThreadKey: `${environmentId}:working` })).at(-1)).toBe("working"); + }); + + it("orders the inbox by the latest return this device observed", () => { + const layout = build({ + inboxReturnAt: (thread) => + thread.id === "finished-early" ? Date.parse("2026-06-01T04:00:00.000Z") : undefined, + }); + expect(ids(layout).slice(1)).toEqual(["finished-early", "finished-late", "asks-approval"]); + }); + + it("places the shelf after queued tasks and before snoozed and settled threads", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "active" }), + makeThread({ id: ThreadId.make("working"), title: "working", runtime: running }), + makeThread({ + id: ThreadId.make("snoozed"), + title: "snoozed", + runtime: running, + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("settled"), + title: "settled", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + workingShelfEnabled: true, + workingShelfExpanded: true, + snoozedShelfExpanded: true, + }); + const items = buildThreadListV2ListItems({ + items: layout.items, + pendingTasks: [makePendingTask("queued")], + workingCount: layout.workingCount, + workingShelfExpanded: true, + workingShelfHeaderIndex: layout.workingShelfHeaderIndex, + snoozedCount: layout.snoozedCount, + snoozedShelfExpanded: true, + snoozedShelfHeaderIndex: layout.snoozedShelfHeaderIndex, + settledCount: layout.settledCount, + settledShelfHeaderIndex: layout.settledShelfHeaderIndex, + }); + expect( + items.map((item) => + item.type === "v2-thread" + ? item.item.thread.id + : item.type === "v2-pending" + ? item.pendingTask.title + : item.type, + ), + ).toEqual([ + "active", + "queued", + "v2-working-shelf", + "working", + "v2-snoozed-shelf", + "snoozed", + "v2-settled-shelf", + "settled", + ]); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 48f13e348b08..f06f649793b1 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -11,6 +11,11 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled" import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { + createInboxReturnTracker, + isThreadWorking, + sortInboxThreadsByReturn, +} from "@t3tools/client-runtime/state/thread-inbox"; import { sortActiveThreadsByOrderKey, resolveSettledThreadTimestamp, @@ -33,6 +38,11 @@ import { export { snoozeWakeLabel }; +/** Working section beta: when this device saw each thread leave the Working + shelf. One instance for Home and the iPad sidebar, so both lists order the + inbox the same way and the order survives screens that unmount. */ +export const threadListInboxReturns = createInboxReturnTracker(); + /** * Provider drivers for a row's trailing icon stack, back to front. Instances * missing from the environment's config are skipped, and an unresolved @@ -269,6 +279,10 @@ export interface ThreadListV2Layout { readonly items: ThreadListV2Item[]; /** Settled threads beyond the render limit (behind "Show more"). */ readonly hiddenSettledCount: number; + /** Working threads folded away by the Working section beta. */ + readonly workingCount: number; + /** Index in `items` where the Working shelf header belongs. */ + readonly workingShelfHeaderIndex: number | null; /** Snoozed threads matching the current filters. */ readonly snoozedCount: number; /** Index in `items` where the Snoozed shelf header belongs. The header is @@ -325,6 +339,15 @@ export interface ThreadListV2PendingListItem { readonly showTrailingDivider: boolean; } +export interface ThreadListV2WorkingShelfListItem { + readonly type: "v2-working-shelf"; + readonly key: "v2-working-shelf"; + readonly count: number; + readonly expanded: boolean; + /** See the snoozed shelf header's field. */ + readonly disabled: boolean; +} + export interface ThreadListV2SnoozedShelfListItem { readonly type: "v2-snoozed-shelf"; readonly key: "v2-snoozed-shelf"; @@ -349,6 +372,7 @@ export interface ThreadListV2SettledShelfListItem { export type ThreadListV2ListItem = | ThreadListV2ThreadListItem | ThreadListV2PendingListItem + | ThreadListV2WorkingShelfListItem | ThreadListV2SnoozedShelfListItem | ThreadListV2SettledShelfListItem; @@ -360,6 +384,7 @@ export function isThreadListV2ListItem(value: { return ( value.type === "v2-thread" || value.type === "v2-pending" || + value.type === "v2-working-shelf" || value.type === "v2-snoozed-shelf" || value.type === "v2-settled-shelf" ); @@ -401,6 +426,13 @@ export function threadListV2ListItemsAreEqual( previous.showPendingDivider === item.showPendingDivider && previous.showTrailingDivider === item.showTrailingDivider ); + case "v2-working-shelf": + return ( + previous.type === "v2-working-shelf" && + previous.count === item.count && + previous.expanded === item.expanded && + previous.disabled === item.disabled + ); case "v2-snoozed-shelf": return ( previous.type === "v2-snoozed-shelf" && @@ -441,13 +473,17 @@ function resolveThreadListV2ItemTimeLabel( } /** - * Builds the shared mobile order: active → pending → snoozed shelf → settled. - * Pending tasks are waiting rather than asking, and parked work remains - * reachable without competing with either the inbox or settled history. + * Builds the shared mobile order: active → pending → working shelf (beta) → + * snoozed shelf → settled. Pending tasks are waiting rather than asking, and + * busy or parked work remains reachable without competing with either the + * inbox or settled history. */ export function buildThreadListV2ListItems(input: { readonly items: ReadonlyArray; readonly pendingTasks: ReadonlyArray; + readonly workingCount?: number; + readonly workingShelfExpanded?: boolean; + readonly workingShelfHeaderIndex?: number | null; readonly snoozedCount?: number; readonly snoozedShelfExpanded?: boolean; readonly snoozedShelfHeaderIndex?: number | null; @@ -514,14 +550,27 @@ export function buildThreadListV2ListItems(input: { showPendingDivider: index === 0, showTrailingDivider: false, })); + const workingCount = input.workingCount ?? 0; + const workingShelfHeaderIndex = input.workingShelfHeaderIndex ?? null; const snoozedCount = input.snoozedCount ?? 0; const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null; const settledCount = input.settledCount ?? 0; const settledShelfHeaderIndex = input.settledShelfHeaderIndex ?? null; - const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length; const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length; + const workingEnd = snoozedShelfHeaderIndex ?? snoozedEnd; + const activeEnd = workingShelfHeaderIndex ?? workingEnd; const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems]; const shelfDisabled = input.shelfPreferencesLoading === true; + if (workingShelfHeaderIndex !== null && workingCount > 0) { + result.push({ + type: "v2-working-shelf", + key: "v2-working-shelf", + count: workingCount, + expanded: input.workingShelfExpanded === true, + disabled: shelfDisabled, + }); + result.push(...threadItems.slice(workingShelfHeaderIndex, workingEnd)); + } if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) { result.push({ type: "v2-snoozed-shelf", @@ -580,6 +629,15 @@ export function buildThreadListV2Items(input: { readonly settledLimit?: number; /** Second-precise clock used for time-based classification. */ readonly now: string; + /** Working section beta: unpinned working threads fold into the Working + shelf, and the inbox orders by when each thread came back to the user + instead of the saved arrangement. */ + readonly workingShelfEnabled?: boolean; + /** Expands the Working shelf into rows. Collapsed is the default. */ + readonly workingShelfExpanded?: boolean; + /** Returns this device observed but the server does not stamp, such as an + approval request mid-turn. Only read while the beta is on. */ + readonly inboxReturnAt?: (thread: EnvironmentThreadShell) => number | undefined; /** Expands the snoozed shelf into rows. Collapsed is the default. */ readonly snoozedShelfExpanded?: boolean; /** Expands the settled shelf into rows. Expanded is the default. */ @@ -609,8 +667,10 @@ export function buildThreadListV2Items(input: { ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) : null; + const workingShelfEnabled = input.workingShelfEnabled === true; const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; + const working: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; let nextSnoozeWakeAt: string | null = null; @@ -656,17 +716,31 @@ export function buildThreadListV2Items(input: { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); + } else if (workingShelfEnabled && isThreadWorking(thread)) { + working.push(thread); } else { active.push(thread); } } - const orderedActive = applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending); + // The beta inbox is time-ordered, so the saved arrangement (and any move in + // flight) is kept but not applied until the beta is off again. + const orderedActive = workingShelfEnabled + ? sortInboxThreadsByReturn(active, input.inboxReturnAt) + : applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending); + // Newest work first, by the same clock as the inbox. + const orderedWorking = sortInboxThreadsByReturn(working); const orderedSnoozed = [...snoozed].sort( (left, right) => parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""), ); const selectedThreadKey = input.selectedThreadKey ?? null; + const visibleWorking = + input.workingShelfExpanded === true + ? orderedWorking + : orderedWorking.filter( + (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey, + ); const visibleSnoozed = input.snoozedShelfExpanded === true ? orderedSnoozed @@ -711,6 +785,16 @@ export function buildThreadListV2Items(input: { isLast: false, }); } + const workingShelfHeaderIndex = orderedWorking.length > 0 ? items.length : null; + for (const thread of visibleWorking) { + items.push({ + thread, + variant: "card", + snoozed: false, + pinned: false, + isLast: false, + }); + } const snoozedShelfHeaderIndex = orderedSnoozed.length > 0 ? items.length : null; for (const thread of visibleSnoozed) { items.push({ @@ -738,6 +822,8 @@ export function buildThreadListV2Items(input: { return { items, hiddenSettledCount: orderedSettled.length - pagedSettled.length, + workingCount: orderedWorking.length, + workingShelfHeaderIndex, snoozedCount: orderedSnoozed.length, snoozedShelfHeaderIndex, settledCount: orderedSettled.length, diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts index 8ffab4e045b4..b32d9ad40043 100644 --- a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts +++ b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts @@ -17,10 +17,16 @@ export function useThreadListV2ShelfPreferences() { loaded && preferencesResult.value.threadListSnoozedShelfExpanded === true; const settledShelfExpanded = loaded && preferencesResult.value.threadListSettledShelfExpanded === true; + // Working section beta: off until the preference loads and is enabled. + const workingShelfEnabled = loaded && preferencesResult.value.workingShelfEnabled === true; + const workingShelfExpanded = + loaded && preferencesResult.value.threadListWorkingShelfExpanded === true; const snoozedShelfExpandedRef = useRef(snoozedShelfExpanded); const settledShelfExpandedRef = useRef(settledShelfExpanded); + const workingShelfExpandedRef = useRef(workingShelfExpanded); snoozedShelfExpandedRef.current = snoozedShelfExpanded; settledShelfExpandedRef.current = settledShelfExpanded; + workingShelfExpandedRef.current = workingShelfExpanded; const toggleSnoozedShelf = useCallback(() => { if (!loaded) return; @@ -34,12 +40,21 @@ export function useThreadListV2ShelfPreferences() { settledShelfExpandedRef.current = expanded; savePreferences({ threadListSettledShelfExpanded: expanded }); }, [loaded, savePreferences]); + const toggleWorkingShelf = useCallback(() => { + if (!loaded) return; + const expanded = !workingShelfExpandedRef.current; + workingShelfExpandedRef.current = expanded; + savePreferences({ threadListWorkingShelfExpanded: expanded }); + }, [loaded, savePreferences]); return { loaded, settledShelfExpanded, snoozedShelfExpanded, + workingShelfEnabled, + workingShelfExpanded, toggleSettledShelf, toggleSnoozedShelf, + toggleWorkingShelf, } as const; } diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index bfc0951b6efa..fda2dd6adb83 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -368,7 +368,7 @@ export function UsageRouteScreen() { /> - + )} @@ -788,14 +788,22 @@ function MetricCell(props: { ); } -function ModelsSection(props: { readonly merged: MergedUsage }) { - const { merged } = props; +function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric }) { + const { merged, metric } = props; const colors = useProviderColors(); if (merged.models.length === 0) return null; + // Ranked like the provider rows. .sort() on a copy, not .toSorted(): Hermes + // doesn't ship the ES2023 method. + const ordered = [...merged.models].sort((a, b) => + metric === "cost" + ? b.costUsd - a.costUsd || b.totalTokens - a.totalTokens + : b.totalTokens - a.totalTokens || b.costUsd - a.costUsd, + ); + return ( - {merged.models.map((model, index) => ( + {ordered.map((model, index) => ( - {isModelCostUnknown(model) - ? `no known rates · ${formatTokens(model.totalTokens)} tokens` - : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} + {metric === "tokens" + ? `${formatPercent(model.tokenShare)} of tokens · ${ + isModelCostUnknown(model) ? "no known rates" : formatUsd(model.costUsd) + }` + : isModelCostUnknown(model) + ? `no known rates · ${formatTokens(model.totalTokens)} tokens` + : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} - {isModelCostUnknown(model) ? "Unpriced" : formatUsd(model.costUsd)} + {metric === "tokens" + ? formatTokens(model.totalTokens) + : isModelCostUnknown(model) + ? "Unpriced" + : formatUsd(model.costUsd)} ))} diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 78a165c5ff1d..f56c3a7eff0e 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -232,24 +232,32 @@ describe("mobile connection storage", () => { expect(fallback.updatedAt).toEqual(expect.any(Number)); }); - it("persists thread list shelf expansion preferences", async () => { + it("persists thread list shelf preferences", async () => { await expect( savePreferencesPatch({ threadListSettledShelfExpanded: false, threadListSnoozedShelfExpanded: true, + threadListWorkingShelfExpanded: true, + workingShelfEnabled: true, }), ).resolves.toEqual({ threadListSettledShelfExpanded: false, threadListSnoozedShelfExpanded: true, + threadListWorkingShelfExpanded: true, + workingShelfEnabled: true, }); await expect(loadPreferences()).resolves.toEqual({ threadListSettledShelfExpanded: false, threadListSnoozedShelfExpanded: true, + threadListWorkingShelfExpanded: true, + workingShelfEnabled: true, }); expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ threadListSettledShelfExpanded: false, threadListSnoozedShelfExpanded: true, + threadListWorkingShelfExpanded: true, + workingShelfEnabled: true, }); }); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 7f0e8adc23e9..af66ac45dc51 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -46,6 +46,8 @@ export interface Preferences { readonly projectGroupingMode?: SidebarProjectGroupingMode; /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ readonly planModeEnabled?: boolean; + /** Device-local counterpart of web's `sidebarWorkingShelfEnabled` beta. */ + readonly workingShelfEnabled?: boolean; /** Model favorites belong to this device, like the web client setting. */ readonly modelFavorites?: ReadonlyArray<{ readonly provider: ProviderInstanceId; @@ -54,6 +56,7 @@ export interface Preferences { /** Fresh keys reset both shelves to collapsed when users update. */ readonly threadListSettledShelfExpanded?: boolean; readonly threadListSnoozedShelfExpanded?: boolean; + readonly threadListWorkingShelfExpanded?: boolean; /** The phone's own wallpaper for home and threads; its pictures live in app storage. */ readonly phoneBackground?: PhoneBackground | null; /** Hides the phone background without discarding it. */ @@ -132,9 +135,11 @@ function sanitizePreferences(parsed: Preferences): Preferences { projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; planModeEnabled?: boolean; + workingShelfEnabled?: boolean; modelFavorites?: Preferences["modelFavorites"]; threadListSettledShelfExpanded?: boolean; threadListSnoozedShelfExpanded?: boolean; + threadListWorkingShelfExpanded?: boolean; phoneBackground?: PhoneBackground; phoneBackgroundEnabled?: boolean; phoneBackgroundQuickAdjust?: boolean; @@ -209,6 +214,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } + if (typeof parsed.workingShelfEnabled === "boolean") { + preferences.workingShelfEnabled = parsed.workingShelfEnabled; + } if (Array.isArray(parsed.modelFavorites)) { preferences.modelFavorites = parsed.modelFavorites.filter( (favorite) => @@ -226,6 +234,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.threadListSnoozedShelfExpanded === "boolean") { preferences.threadListSnoozedShelfExpanded = parsed.threadListSnoozedShelfExpanded; } + if (typeof parsed.threadListWorkingShelfExpanded === "boolean") { + preferences.threadListWorkingShelfExpanded = parsed.threadListWorkingShelfExpanded; + } const phoneBackground = decodePhoneBackground(parsed.phoneBackground); if (Option.isSome(phoneBackground)) { preferences.phoneBackground = phoneBackground.value; diff --git a/apps/server/src/atomicWrite.test.ts b/apps/server/src/atomicWrite.test.ts new file mode 100644 index 000000000000..172c9703a304 --- /dev/null +++ b/apps/server/src/atomicWrite.test.ts @@ -0,0 +1,139 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; + +import { writeFileStringAtomically } from "./atomicWrite.ts"; + +it.layer(NodeServices.layer)("writeFileStringAtomically", (it) => { + it.effect("keeps a symlinked file linked and rewrites its destination", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); + const destination = path.join(root, "dotfiles", "settings.json"); + const link = path.join(root, "home", "settings.json"); + yield* fs.makeDirectory(path.dirname(destination), { recursive: true }); + yield* fs.makeDirectory(path.dirname(link), { recursive: true }); + yield* fs.writeFileString(destination, "before"); + yield* fs.symlink(destination, link); + + yield* writeFileStringAtomically({ filePath: link, contents: "after" }); + + assert.strictEqual(yield* fs.readLink(link), destination); + assert.strictEqual(yield* fs.readFileString(destination), "after"); + }), + ); + + it.effect("keeps a dangling symlink linked and creates its destination", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); + const destination = path.join(root, "dotfiles", "settings.json"); + const link = path.join(root, "home", "settings.json"); + yield* fs.makeDirectory(path.dirname(link), { recursive: true }); + yield* fs.symlink(destination, link); + + yield* writeFileStringAtomically({ filePath: link, contents: "fresh" }); + + assert.strictEqual(yield* fs.readLink(link), destination); + assert.strictEqual(yield* fs.readFileString(destination), "fresh"); + }), + ); + + it.effect("fails on a symlink cycle without replacing either link", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); + const first = path.join(root, "first.json"); + const second = path.join(root, "second.json"); + yield* fs.symlink(second, first); + yield* fs.symlink(first, second); + + const result = yield* Effect.exit( + writeFileStringAtomically({ filePath: first, contents: "after" }), + ); + + assert.isTrue(Exit.isFailure(result)); + assert.strictEqual(yield* fs.readLink(first), second); + assert.strictEqual(yield* fs.readLink(second), first); + }), + ); + + it.effect("resolves a relative link through a symlinked parent directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); + const destination = path.join(root, "dotfiles", "config", "settings.json"); + const linkedState = path.join(root, "dotfiles", "state"); + const home = path.join(root, "home"); + const link = path.join(home, "state", "settings.json"); + yield* fs.makeDirectory(path.dirname(destination), { recursive: true }); + yield* fs.makeDirectory(linkedState, { recursive: true }); + yield* fs.makeDirectory(home, { recursive: true }); + yield* fs.symlink(linkedState, path.join(home, "state")); + yield* fs.writeFileString(destination, "before"); + yield* fs.symlink("../config/settings.json", link); + + yield* writeFileStringAtomically({ filePath: link, contents: "after" }); + + assert.strictEqual(yield* fs.readLink(link), "../config/settings.json"); + assert.strictEqual(yield* fs.readFileString(destination), "after"); + }), + ); + + it.effect("creates a missing file and its directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); + const filePath = path.join(root, "nested", "settings.json"); + + yield* writeFileStringAtomically({ filePath, contents: "fresh" }); + + assert.strictEqual(yield* fs.readFileString(filePath), "fresh"); + }), + ); +}); + +it.effect("surfaces an unreadable link instead of writing over it", () => + Effect.gen(function* () { + const readLinkFailure = PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "readLink", + pathOrDescriptor: "/home/settings.json", + }); + + const result = yield* Effect.exit( + writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }), + ); + + assert.deepStrictEqual(result, Exit.fail(readLinkFailure)); + }).pipe( + Effect.provide( + Layer.mergeAll( + Path.layer, + FileSystem.layerNoop({ + readLink: () => + Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "readLink", + pathOrDescriptor: "/home/settings.json", + }), + ), + rename: () => Effect.die("an unreadable link must not be replaced"), + }), + ), + ), + ), +); diff --git a/apps/server/src/atomicWrite.ts b/apps/server/src/atomicWrite.ts index fcd0345fc8d6..8a7fb20f2aea 100644 --- a/apps/server/src/atomicWrite.ts +++ b/apps/server/src/atomicWrite.ts @@ -1,7 +1,13 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; +/** + * Replaces a file's contents via a sibling temp file and rename. A symlinked + * target is resolved first so the link survives and its destination is + * rewritten, since renaming over the link itself would swap it for a regular file. + */ export const writeFileStringAtomically = (input: { readonly filePath: string; readonly contents: string; @@ -10,16 +16,17 @@ export const writeFileStringAtomically = (input: { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const targetDirectory = path.dirname(input.filePath); + const targetPath = yield* resolveSymlinkTarget(input.filePath); + const targetDirectory = path.dirname(targetPath); yield* fs.makeDirectory(targetDirectory, { recursive: true }); const tempDirectory = yield* fs.makeTempDirectoryScoped({ directory: targetDirectory, - prefix: `${path.basename(input.filePath)}.`, + prefix: `${path.basename(targetPath)}.`, }); const tempPath = path.join(tempDirectory, "contents.tmp"); yield* fs.writeFileString(tempPath, input.contents); - yield* fs.rename(tempPath, input.filePath); + yield* fs.rename(tempPath, targetPath); }), ); diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index d5b165865ab2..fc355f66a9eb 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -7,11 +7,15 @@ import { WsRpcGroup, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as RpcTest from "effect/unstable/rpc/RpcTest"; import { RPC_REQUIRED_SCOPES, requiredScopeForRpcMethod, requiredScopeForDeviceList, + rpcScopeAuthorizationLayer, } from "./RpcAuthorization.ts"; describe("RPC authorization scopes", () => { @@ -116,3 +120,39 @@ it("requires operate permission for tool updates even alongside a read-only chec ); expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope); }); + +describe("RPC scope middleware", () => { + const tested = [WS_METHODS.serverProbe, WS_METHODS.serverRetryResourceTelemetry] as const; + const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => + !(tested as ReadonlyArray).includes(tag), + ), + ); + + it.effect("checks each RPC's declared scope before its handler runs", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.serverProbe, () => Effect.succeed({})), + group.toLayerHandler(WS_METHODS.serverRetryResourceTelemetry, () => + Effect.sync(() => handled.push("retry")).pipe(Effect.andThen(Effect.never)), + ), + rpcScopeAuthorizationLayer([AuthOrchestrationReadScope]), + ), + ), + ); + + expect(yield* client[WS_METHODS.serverProbe]({})).toEqual({}); + expect( + yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip), + ).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredScope: AuthOrchestrationOperateScope, + }); + expect(handled).toEqual([]); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5bebaec8026f..5028ed11724c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -9,9 +9,13 @@ import { AuthTerminalOperateScope, ORCHESTRATION_V2_WS_METHODS, type AuthEnvironmentScope, + EnvironmentAuthorizationError, + RpcScopeAuthorization, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import type * as RpcGroup from "effect/unstable/rpc/RpcGroup"; import { SERVER_PUSH_RPC_SCOPES } from "../serverPush/serverPushRpcScopes.ts"; @@ -217,6 +221,21 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope return requiredScope; } +export const rpcAuthorizationError = (requiredScope: AuthEnvironmentScope) => + new EnvironmentAuthorizationError({ + message: `The authenticated token is missing required scope: ${requiredScope}.`, + requiredScope, + }); + +/** Authorizes every RPC on one connection against that connection's session scopes. */ +export const rpcScopeAuthorizationLayer = (scopes: ReadonlyArray) => + Layer.succeed(RpcScopeAuthorization)((effect, { rpc }) => { + const requiredScope = requiredScopeForRpcMethod(rpc._tag); + return scopes.includes(requiredScope) + ? effect + : Effect.fail(rpcAuthorizationError(requiredScope)); + }); + /** Retrying can install or restart tools even though ordinary listing is readable. */ export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope => input.retryHostId || input.updateTool diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 71be05d696a2..4499bab763c7 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -236,6 +236,7 @@ export const make = Effect.gen(function* () { environmentThemes: true, usageLimitSources: true, usagePriceOverrides: true, + usageModelAliases: true, threadPinning: true, threadPinReorder: true, threadActiveReorder: true, diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 3f927c283ef8..eae4ffa8c0b1 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -11,10 +11,12 @@ import { type OrchestrationV2ThreadProjection, type ServerProvider, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts"; import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; @@ -52,7 +54,22 @@ describe("OrchestratorMcpService", () => { } as unknown as OrchestrationV2ThreadProjection; const childProjection = { thread: { id: childThreadId }, - runs: [{ id: childRunId, ordinal: 1, status: "completed" }], + runs: [ + { + id: childRunId, + ordinal: 1, + status: "completed", + startedAt: DateTime.makeUnsafe("2026-10-03T10:00:00Z"), + completedAt: DateTime.makeUnsafe("2026-10-03T10:10:00Z"), + }, + { + id: RunId.make("run:mcp-ack-continuation"), + ordinal: 2, + status: "failed", + startedAt: DateTime.makeUnsafe("2026-10-03T10:02:00Z"), + completedAt: DateTime.makeUnsafe("2026-10-03T10:05:00Z"), + }, + ], contextTransfers: [], messages: [], subagents: [], @@ -126,6 +143,8 @@ describe("OrchestratorMcpService", () => { const result = yield* service.taskStatus(scope, taskId); assert.equal(result.status, "completed"); assert.equal(result.summary, "terminal result"); + assert.equal(result.latestTerminalRunId, childRunId); + assert.equal(result.latestTerminalStatus, "completed"); const commandIds = yield* Ref.get(acknowledgementCommandIds); assert.equal(commandIds.length, 2); assert.notEqual(commandIds[0], commandIds[1]); @@ -133,6 +152,87 @@ describe("OrchestratorMcpService", () => { }), ); + it.effect("reports a restart-cut child as working until its continuation settles", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-restart-parent"); + const childThreadId = ThreadId.make("thread:mcp-restart-child"); + const taskId = NodeId.make("node:mcp-restart-task"); + const dispatched = yield* Ref.make(0); + let awaitsRestart = true; + let readFails = true; + const parentProjection = { + thread: { id: parentThreadId }, + runs: [], + contextTransfers: [], + subagents: [ + { + id: taskId, + threadId: parentThreadId, + origin: "app_owned", + childThreadId, + driver: "codex", + model: "gpt-5.6-terra", + status: "running", + result: null, + completionDelivery: { state: "pending" }, + }, + ], + } as unknown as OrchestrationV2ThreadProjection; + const childProjection = { + thread: { id: childThreadId }, + runs: [{ id: RunId.make("run:mcp-restart-child"), ordinal: 1, status: "cancelled" }], + contextTransfers: [], + messages: [], + subagents: [], + providerThreads: [], + turnItems: [], + } as unknown as OrchestrationV2ThreadProjection; + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), + delegatedTaskResultPending: () => + readFails + ? Effect.fail(new OrchestratorProjectionError({ threadId: childThreadId })) + : Effect.succeed(awaitsRestart), + dispatch: () => Ref.update(dispatched, (count) => count + 1).pipe(Effect.as({} as never)), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-restart"), + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-restart", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const failed = yield* service.taskStatus(scope, taskId).pipe(Effect.flip); + assert.equal(failed.code, "orchestration_error"); + assert.equal(yield* Ref.get(dispatched), 0); + readFails = false; + const held = yield* service.taskStatus(scope, taskId); + assert.equal(held.status, "running"); + assert.equal(held.workState, "working"); + assert.isNull(held.summary); + // Acknowledging the cut run would suppress the real result's wake. + assert.equal(yield* Ref.get(dispatched), 0); + awaitsRestart = false; + const settled = yield* service.taskStatus(scope, taskId); + assert.equal(settled.status, "cancelled"); + assert.equal(yield* Ref.get(dispatched), 1); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + }), + ); + it.effect("does not dispose delivery when a nonterminal task has no active child run", () => Effect.gen(function* () { const parentThreadId = ThreadId.make("thread:mcp-cancel-parent"); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 9c7323ce546a..83a7a4cbfde6 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -52,6 +52,7 @@ import { type ServerProvider, ThreadId, } from "@t3tools/contracts"; +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -412,7 +413,10 @@ function latestTerminalResultRun( run.status !== "rolled_back" && (run.id === delegatedRun?.id || run.startedAt !== null), ) - .toSorted((left, right) => right.ordinal - left.ordinal)[0]; + .reduce( + (latest, run) => (latest === undefined || runRanAfter(run, latest) ? run : latest), + undefined, + ); } function canExposeTaskRunResult(run: OrchestrationV2Run | undefined): run is OrchestrationV2Run { @@ -1055,7 +1059,16 @@ const make = Effect.gen(function* () { messages: [...childControls.messages, ...resultRecords.messages], turnItems: resultRecords.turnItems, }; - const workState = task.result !== null ? "result_available" : progress.state; + // A restart cut the child's run and its continuation has not settled, or + // the child started working again after this read. + const heldForRestart = + task.result === null && + progress.state === "result_available" && + (yield* threadManagement + .delegatedTaskResultPending(task.childThreadId) + .pipe(Effect.mapError(threadManagementFailure))); + const workState = + task.result !== null ? "result_available" : heldForRestart ? "working" : progress.state; const status = task.result !== null ? taskStatusForRun( diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 7a8ed739d734..adf55cad5e91 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { + AuthOrchestrationOperateScope, EnvironmentId, PreviewAutomationClientDisconnectedError, PreviewAutomationInvalidSelectorError, @@ -17,6 +18,7 @@ import { type PreviewAutomationStreamEvent, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Exit from "effect/Exit"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; @@ -27,6 +29,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; import * as RpcTest from "effect/unstable/rpc/RpcTest"; +import { rpcScopeAuthorizationLayer } from "../auth/RpcAuthorization.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer)); @@ -1255,9 +1258,12 @@ it.effect("evicts an unanswered host and lets later calls use a healthy runtime" ); const client = yield* RpcTest.makeClient(group).pipe( Effect.provide( - group.toLayer({ - [WS_METHODS.previewAutomationConnect]: (host) => Stream.unwrap(broker.connect(host)), - }), + Layer.merge( + group.toLayer({ + [WS_METHODS.previewAutomationConnect]: (host) => Stream.unwrap(broker.connect(host)), + }), + rpcScopeAuthorizationLayer([AuthOrchestrationOperateScope]), + ), ), ); const events = client[WS_METHODS.previewAutomationConnect](makeHost()); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 146924586763..b6cb0be45acf 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -4925,9 +4925,13 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -4950,7 +4954,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -4986,28 +4989,13 @@ describe("AcpAdapterV2", () => { makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); // The still-running subagent defers finalize after session/prompt returns. - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const providerTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId }); let terminalStatus: string | null = null; while (terminalStatus === null) { @@ -5033,10 +5021,14 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5072,7 +5064,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -5107,28 +5098,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let subagentTurnItemId: string | null = null; let firstTerminalStatus: string | null = null; @@ -5193,10 +5169,14 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5230,7 +5210,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -5272,28 +5251,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -5352,7 +5316,6 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const secondPromptWireReturned = yield* Deferred.make(); const releaseSecondPromptCompletion = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test"); @@ -5361,7 +5324,12 @@ describe("AcpAdapterV2", () => { let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5395,7 +5363,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -5456,28 +5423,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -5601,7 +5553,6 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const baseClock = yield* Clock.Clock; const finalizationClockRead = yield* Deferred.make(); const releaseFinalizationClockRead = yield* Deferred.make(); @@ -5629,7 +5580,12 @@ describe("AcpAdapterV2", () => { let sessionUpdateHandler: | Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5668,7 +5624,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -5719,18 +5674,7 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); yield* TestClock.adjust("1 second"); assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); @@ -6451,14 +6395,18 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const continuationRequests: Array = []; const bufferedAssistantText = "BUFFERED_WAKE_AFTER_USER_ATTACH"; const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -6492,7 +6440,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -6548,28 +6495,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -6752,14 +6684,18 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const continuationRequests: Array = []; - const protocolEvents = yield* Queue.bounded(256); const promptWireReturned = yield* Deferred.make(); const releasePromptCompletion = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -6795,7 +6731,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -6875,18 +6810,13 @@ describe("AcpAdapterV2", () => { }, }); yield* Deferred.succeed(releasePromptCompletion, undefined); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -7223,7 +7153,6 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const continuationRequests: Array = []; const instanceId = ProviderInstanceId.make("acp-test"); const childSessionId = "mock-child-session-pending-continuation"; @@ -7231,7 +7160,12 @@ describe("AcpAdapterV2", () => { let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -7265,7 +7199,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -7321,28 +7254,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -7506,14 +7424,18 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const continuationRequests: Array = []; const instanceId = ProviderInstanceId.make("acp-test"); const childSessionId = "mock-child-session-root-then-child"; let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -7547,7 +7469,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -7603,28 +7524,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -7760,12 +7666,16 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; let cancelCalled = false; let runtimeOrdinalSeen = 0; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -7812,7 +7722,6 @@ describe("AcpAdapterV2", () => { runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); return { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }; }, - protocolEvents, wrapCancel: (cancel) => Effect.sync(() => { cancelCalled = true; @@ -7853,28 +7762,13 @@ describe("AcpAdapterV2", () => { ); // The still-running subagent defers finalize after session/prompt returns, // so the interrupt below hits a settled turn held open for background work. - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); assert.isFalse( cancelCalled, "settled soft steer must not send session/cancel (the real Grok CLI kills background subagents on cancel)", @@ -13725,10 +13619,14 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -13762,7 +13660,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -13799,32 +13696,17 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ - providerThread, - providerTurnId: firstProviderTurnId, - requestRuntimeRestart: true, - }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: firstProviderTurnId, + requestRuntimeRestart: true, + }); let subagentStatus: string | null = null; let firstTerminalStatus: string | null = null; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 3145e33dabd1..85748fa056e7 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -2597,6 +2597,161 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + it.effect.each( + ( + [ + ["recovers archived-session errors", "session saved-thread is archived", 0, ""], + [ + "recovers unarchive hints", + "Run `codex unarchive saved-thread` to unarchive it first.", + 0, + "", + ], + ["preserves missing-thread errors", "thread not found", 3, "thread not found"], + ["preserves missing-rollout errors", "no rollout found", 3, "no rollout found"], + ["preserves authentication errors", "authentication failed", 3, "authentication failed"], + [ + "preserves archived-workspace errors", + "workspace is archived", + 3, + "workspace is archived", + ], + [ + "preserves unrelated archive-path errors", + "permission denied reading archived_sessions/saved-thread", + 3, + "permission denied reading archived_sessions/saved-thread", + ], + [ + "propagates unarchive missing-thread errors", + "session saved-thread is archived", + 4, + "thread not found", + ], + [ + "propagates unarchive archived errors", + "session saved-thread is archived", + 4, + "session saved-thread is archived", + ], + [ + "propagates retry missing-thread errors", + "session saved-thread is archived", + 5, + "thread not found", + ], + [ + "does not retry archived errors twice", + "session saved-thread is archived", + 5, + "session saved-thread is archived", + ], + ] as const + ).map(([name, resumeError, failAt, finalError]) => ({ name, resumeError, failAt, finalError })), + )("$name", ({ name, resumeError, failAt, finalError }) => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "saved-thread"; + const params = { + threadId: nativeThreadId, + excludeTurns: true, + cwd: CODEX_TEST_RUNTIME_POLICY.cwd, + model: CODEX_TEST_MODEL_SELECTION.model, + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }; + const entries: Array = [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "unused-turn", + prompt: "unused-prompt", + }).slice(0, 5), + { + type: "expect_outbound", + label: "resume archived thread", + frame: { id: 3, method: "thread/resume", params }, + }, + { + type: "emit_inbound", + label: "resume error", + frame: { id: 3, error: { code: -32600, message: resumeError } }, + }, + ]; + if (failAt !== 3) { + entries.push( + { + type: "expect_outbound", + label: "unarchive same thread", + frame: { id: 4, method: "thread/unarchive", params: { threadId: nativeThreadId } }, + }, + { + type: "emit_inbound", + label: "unarchive result", + frame: + failAt === 4 + ? { id: 4, error: { code: -32600, message: finalError } } + : { id: 4, result: { thread: { turns: [{ type: "unknown-history-item" }] } } }, + }, + ); + } + if (failAt === 0 || failAt === 5) { + entries.push( + { + type: "expect_outbound", + label: "retry identical resume", + frame: { id: 5, method: "thread/resume", params }, + }, + { + type: "emit_inbound", + label: "retry result", + frame: + failAt === 5 + ? { id: 5, error: { code: -32600, message: finalError } } + : { + id: 5, + result: { + thread: { + id: nativeThreadId, + updatedAt: 1782622450, + turns: [{ type: "unknown-history-item" }], + }, + }, + }, + }, + ); + } + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ scenario: name, entries }), + ); + const resume = harness.runtime.resumeThread({ + providerThread: harness.providerThread, + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }); + if (failAt !== 0) { + const error = yield* Effect.flip(resume); + assert.equal(error._tag, "ProviderAdapterResumeThreadError"); + assert.nestedPropertyVal(error, "cause.errorMessage", finalError); + assert.nestedPropertyVal( + error, + "cause.method", + failAt === 4 ? "thread/unarchive" : "thread/resume", + ); + assert.nestedPropertyVal(error, "cause.requestId", String(failAt)); + return; + } + const resumed = yield* resume; + assert.equal(resumed.id, harness.providerThread.id); + assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); + assert.deepEqual( + resumed.nativeConversationHeadRef, + harness.providerThread.nativeConversationHeadRef, + ); + assert.equal(resumed.status, "idle"); + assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + it.effect("continues an interrupted native thread with empty input and reasoning summaries", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 19c3486f5af7..8da6ffe4357d 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -5429,23 +5429,39 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi resumeThread: (threadInput) => Effect.gen(function* () { const nativeThreadId = yield* getNativeThreadId(threadInput.providerThread); - + // excludeTurns is not in the generated request schema yet. + const resume = client.raw.request("thread/resume", { + threadId: nativeThreadId, + excludeTurns: true, + ...codexThreadRuntimeParams({ + threadId: threadInput.threadId ?? threadInput.providerThread.appThreadId, + ...(threadInput.modelSelection === undefined + ? {} + : { modelSelection: threadInput.modelSelection }), + ...(threadInput.runtimePolicy === undefined + ? {} + : { runtimePolicy: threadInput.runtimePolicy }), + }), + }); const response = yield* ensureInitialized.pipe( Effect.andThen( - // excludeTurns is not in the generated request schema yet. - client.raw.request("thread/resume", { - threadId: nativeThreadId, - excludeTurns: true, - ...codexThreadRuntimeParams({ - threadId: threadInput.threadId ?? threadInput.providerThread.appThreadId, - ...(threadInput.modelSelection === undefined - ? {} - : { modelSelection: threadInput.modelSelection }), - ...(threadInput.runtimePolicy === undefined - ? {} - : { runtimePolicy: threadInput.runtimePolicy }), + resume.pipe( + Effect.catchTags({ + CodexAppServerRequestError: (cause) => { + if ( + !/\bsession \S+ is archived\b|\bcodex unarchive\b/i.test( + cause.errorMessage, + ) + ) { + return Effect.fail(cause); + } + // Keep the session's history without decoding the unarchive response. + return client.raw + .request("thread/unarchive", { threadId: nativeThreadId }) + .pipe(Effect.andThen(resume)); + }, }), - }), + ), ), Effect.flatMap(decodeCodexResumeMetadata), ); diff --git a/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts new file mode 100644 index 000000000000..2b1c529ba177 --- /dev/null +++ b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts @@ -0,0 +1,446 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + NodeId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + ProviderTurnId, + RunAttemptId, + RunId, + ThreadId, + TurnItemId, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as EffectWorker from "./EffectWorker.ts"; +import * as EventSink from "./EventSink.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import type { + ProviderAdapterV2Event, + ProviderAdapterV2InterruptInput, + ProviderAdapterV2Shape, + ProviderAdapterV2TurnInput, +} from "./ProviderAdapter.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +const driver = ProviderDriverKind.make("codex"); +const instanceId = ProviderInstanceId.make("codex"); +const modelSelection = { instanceId, model: "test-model" }; + +// Codex turns leave commands running, then the thread moves to another +// provider thread (a provider switch). Stop on the newer, settled run must +// reach both provider threads and end all of the Codex work. +it.effect("Stop reaches background work an earlier provider thread still runs", () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("background-work-stop"); + const events = yield* Queue.unbounded(); + const started: ProviderAdapterV2TurnInput[] = []; + const interrupts: ProviderAdapterV2InterruptInput[] = []; + const adapter: ProviderAdapterV2Shape = { + instanceId, + driver, + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), + openSession: (input) => + Effect.gen(function* () { + const now = yield* DateTime.now; + return { + instanceId, + driver, + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, + driver, + providerInstanceId: instanceId, + status: "ready", + cwd, + model: modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.fromQueue(events), + ensureThread: ({ threadId }) => + Effect.succeed({ + id: ProviderThreadId.make(`provider-thread:codex:${threadId}`), + driver, + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }), + resumeThread: ({ providerThread }) => Effect.succeed(providerThread), + startTurn: (turn) => + Effect.gen(function* () { + started.push(turn); + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { + id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), + providerThreadId: turn.providerThread.id, + nodeId: turn.rootNodeId, + runAttemptId: turn.attemptId, + nativeTurnRef: { + driver, + nativeId: `native:${turn.attemptId}`, + strength: "strong", + }, + ordinal: turn.providerTurnOrdinal, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + }), + steerTurn: () => Effect.die("unused"), + interruptTurn: (interrupt) => + Effect.sync(() => { + interrupts.push(interrupt); + }), + respondToRuntimeRequest: () => Effect.die("unused"), + readThreadSnapshot: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + forkThread: () => Effect.die("unused"), + }; + }), + }; + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const sink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make("thread:background-work-stop"); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:background-work-stop"), + title: "Background work stop", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const running = yield* watch( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("start-dev-server"), + threadId, + messageId: MessageId.make("message:start-dev-server"), + text: "Start the dev server", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* Fiber.join(running); + const first = started[0]!; + const codexTurn = (yield* orchestrator.getThreadProjection(threadId)).providerTurns[0]!; + const devServerId = TurnItemId.make("turn-item:dev-server"); + const now = yield* DateTime.now; + yield* sink.write({ + events: [ + { + id: EventId.make("dev-server"), + type: "turn-item.updated", + threadId, + runId: first.runId, + occurredAt: now, + payload: { + id: devServerId, + threadId, + runId: first.runId, + nodeId: first.rootNodeId, + providerThreadId: codexTurn.providerThreadId, + providerTurnId: codexTurn.id, + nativeItemRef: null, + parentItemId: null, + ordinal: 100, + status: "running", + title: null, + startedAt: now, + completedAt: null, + updatedAt: now, + type: "command_execution", + input: "vp run dev --share", + }, + }, + ], + }); + const settled = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.id === first.runId && + event.payload.status === "waiting", + ); + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...codexTurn, status: "completed", completedAt: now }, + }); + yield* Queue.offer(events, { + type: "turn.terminal", + driver, + providerThreadId: codexTurn.providerThreadId, + providerTurnId: codexTurn.id, + runOrdinal: first.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(settled); + yield* worker.drain(); + + const codexProviderThread = (yield* orchestrator.getThreadProjection(threadId)) + .providerThreads[0]!; + // A settled run with its root node, attempt, provider turn and, + // optionally, a command or native subagent it left running. + const settledRun = (input: { + readonly ordinal: number; + readonly providerThreadId: ProviderThreadId; + readonly runningItem?: { readonly id: TurnItemId; readonly kind: "command" | "subagent" }; + }) => { + const runId = RunId.make(`run:${input.ordinal}`); + const attemptId = RunAttemptId.make(`attempt:${input.ordinal}`); + const nodeId = NodeId.make(`node:${input.ordinal}`); + const providerTurnId = ProviderTurnId.make(`provider-turn:${input.ordinal}`); + const events: Array = [ + { + id: EventId.make(`run:${input.ordinal}`), + type: "run.created", + threadId, + occurredAt: now, + payload: { + id: runId, + threadId, + ordinal: input.ordinal, + providerInstanceId: instanceId, + modelSelection, + providerThreadId: input.providerThreadId, + userMessageId: MessageId.make(`message:${input.ordinal}`), + rootNodeId: nodeId, + activeAttemptId: attemptId, + status: "completed", + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, + }, + }, + { + id: EventId.make(`node:${input.ordinal}`), + type: "node.updated", + threadId, + runId, + occurredAt: now, + payload: { + id: nodeId, + threadId, + runId, + parentNodeId: null, + rootNodeId: nodeId, + kind: "root_turn", + status: "completed", + countsForRun: true, + providerThreadId: input.providerThreadId, + providerTurnId, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: now, + }, + }, + { + id: EventId.make(`attempt:${input.ordinal}`), + type: "run-attempt.created", + threadId, + occurredAt: now, + payload: { + id: attemptId, + runId, + attemptOrdinal: 1, + rootNodeId: nodeId, + providerInstanceId: instanceId, + providerThreadId: input.providerThreadId, + providerTurnId, + reason: "initial", + status: "completed", + startedAt: now, + completedAt: now, + }, + }, + { + id: EventId.make(`provider-turn:${input.ordinal}`), + type: "provider-turn.updated", + threadId, + occurredAt: now, + payload: { + id: providerTurnId, + providerThreadId: input.providerThreadId, + nodeId, + runAttemptId: attemptId, + nativeTurnRef: null, + ordinal: input.ordinal, + status: "completed", + startedAt: now, + completedAt: now, + }, + }, + ]; + const item = input.runningItem; + if (item !== undefined) { + const base = { + id: item.id, + threadId, + runId, + nodeId, + providerThreadId: input.providerThreadId, + providerTurnId, + nativeItemRef: null, + parentItemId: null, + ordinal: input.ordinal * 100, + status: "running", + title: null, + startedAt: now, + completedAt: null, + updatedAt: now, + } as const; + events.push({ + id: EventId.make(`item:${input.ordinal}`), + type: "turn-item.updated", + threadId, + runId, + occurredAt: now, + payload: + item.kind === "command" + ? { ...base, type: "command_execution", input: "vp run test --watch" } + : { + ...base, + // A native subagent item names its own provider thread + // but its parent's provider turn. + providerThreadId: ProviderThreadId.make("provider-thread:codex-subagent"), + type: "subagent", + subagentId: NodeId.make(`subagent:${input.ordinal}`), + origin: "provider_native", + driver, + providerInstanceId: instanceId, + childThreadId: null, + prompt: "Review the change", + result: null, + }, + }); + } + return { runId, providerTurnId, events }; + }; + + // Later Codex runs leave a second command and a native subagent. Then + // the thread moves on to another provider thread, which also has a + // live session. + const watcherId = TurnItemId.make("turn-item:watcher"); + const watcherRun = settledRun({ + ordinal: 2, + providerThreadId: codexProviderThread.id, + runningItem: { id: watcherId, kind: "command" }, + }); + const reviewerId = TurnItemId.make("turn-item:reviewer"); + const reviewerRun = settledRun({ + ordinal: 3, + providerThreadId: codexProviderThread.id, + runningItem: { id: reviewerId, kind: "subagent" }, + }); + const otherProviderThreadId = ProviderThreadId.make("provider-thread:other"); + const latestRun = settledRun({ ordinal: 4, providerThreadId: otherProviderThreadId }); + yield* sink.write({ + events: [ + ...watcherRun.events, + ...reviewerRun.events, + { + id: EventId.make("provider-thread:other"), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: { + ...codexProviderThread, + id: otherProviderThreadId, + firstRunOrdinal: 4, + lastRunOrdinal: 4, + }, + }, + ...latestRun.events, + ], + }); + + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("stop-background-work"), + threadId, + runId: latestRun.runId, + }); + yield* worker.drain(); + + // Stop reaches both provider threads. The Codex one is interrupted at + // its latest pending work, the subagent's parent turn, so its settle + // covers all three Codex runs. + assert.sameDeepMembers( + interrupts.map((interrupt) => [interrupt.providerThread.id, interrupt.providerTurnId]), + [ + [otherProviderThreadId, latestRun.providerTurnId], + [codexProviderThread.id, reviewerRun.providerTurnId], + ], + ); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + [devServerId, watcherId, reviewerId].map( + (id) => after.turnItems.find((item) => item.id === id)?.status, + ), + ["interrupted", "interrupted", "interrupted"], + ); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: "background-work-stop" }, + ProviderAdapterRegistry.makeSingleLayer(adapter), + { runEffectWorker: false }, + ), + ), + ); + }), + ), +); diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index a64dc21cec91..e11338775462 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -7,6 +7,7 @@ import { MessageId, type ModelSelection, NodeId, + type OrchestrationV2Run, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -35,6 +36,7 @@ import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import { continueRestartedRun } from "./RestartContinuation.ts"; import { OrchestrationV2EventSinkLayerLive, OrchestrationV2LayerLive, @@ -384,6 +386,98 @@ it.layer(TestLayer)("delegated completion delivery repairs", (it) => { }), ); + it.effect("acceptance batches a settled_only sibling once its spawning run ended", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("mailbox-mixed"); + const runId = RunId.make("mailbox-mixed-parent"); + const taskId = NodeId.make("mailbox-mixed-always"); + const siblingId = NodeId.make("mailbox-mixed-settled-only"); + const messageId = MessageId.make(`message:delegated-delivery:${threadId}`); + yield* seedParentWithTerminalTask({ + threadId, + runId, + projectId: ProjectId.make("mailbox-mixed-project"), + rootNodeId: NodeId.make("mailbox-mixed-root"), + taskId, + deliveryState: "claimed", + completionWake: "always", + deliveryTaskIds: [taskId], + now, + }); + const projection = yield* orchestrator.getThreadProjection(threadId); + const task = projection.subagents[0]!; + const spawningRun = projection.runs.find((row) => row.id === runId)!; + // A restart cut the spawning run; its continuation is the live run now. + yield* sink.write({ + events: [ + { + ...runEvent({ threadId, runId, ordinal: 1, status: "cancelled", now }), + payload: { ...spawningRun, status: "cancelled" as const, completedAt: now }, + }, + runEvent({ + threadId, + runId: RunId.make("mailbox-mixed-continuation"), + ordinal: 2, + status: "running", + now, + }), + { + id: EventId.make("mailbox-mixed-message"), + type: "message.updated", + threadId, + runId, + occurredAt: now, + payload: { + id: messageId, + threadId, + runId, + nodeId: task.parentNodeId, + role: "user", + text: "Background task finished", + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + createdAt: now, + updatedAt: now, + delegatedCompletion: { parentRunId: runId, generation: 1, taskIds: [taskId] }, + }, + }, + { + id: EventId.make(`event:${siblingId}`), + type: "subagent.updated" as const, + threadId, + runId, + nodeId: siblingId, + occurredAt: now, + payload: { + ...task, + id: siblingId, + completionWake: "settled_only" as const, + completionDelivery: { state: "pending" as const, observedByRunId: null }, + }, + }, + ], + }); + yield* orchestrator.dispatch({ + type: "notification.delivery.accept", + commandId: CommandId.make("accept-mixed"), + threadId, + messageId, + }); + const accepted = yield* orchestrator.getThreadProjection(threadId); + const cohort = accepted.runs.find((row) => row.id === runId)?.delegatedCompletion; + assert.deepEqual(cohort?.delivery?.taskIds, [siblingId]); + assert.equal( + accepted.subagents.find((row) => row.id === siblingId)?.completionDelivery?.state, + "claimed", + ); + }), + ); + it.effect("builds completion text and metadata from the same live cohort", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -652,3 +746,533 @@ it.layer(TestLayer)("delegated completion delivery repairs", (it) => { }), ); }); + +// Runtime reconciliation writes restart cancellations under this command +// prefix, which the live terminal-run listener skips. +const reconcileCommandId = (name: string) => CommandId.make(`command:runtime-reconcile:${name}`); + +const parentProviderThreadId = (threadId: ThreadId) => + ProviderThreadId.make(`provider-thread:${String(threadId).replace("thread:", "")}`); + +const runEvent = (input: { + readonly threadId: ThreadId; + readonly runId: RunId; + readonly ordinal: number; + readonly status: OrchestrationV2Run["status"]; + readonly now: DateTime.Utc; + readonly providerThreadId?: ProviderThreadId; + readonly userMessageId?: MessageId; + readonly delegatedCompletion?: OrchestrationV2Run["delegatedCompletion"]; +}) => ({ + id: EventId.make(`event:${input.runId}:${input.status}`), + type: "run.updated" as const, + threadId: input.threadId, + runId: input.runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: input.now, + payload: { + id: input.runId, + threadId: input.threadId, + ordinal: input.ordinal, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: input.providerThreadId ?? null, + userMessageId: input.userMessageId ?? MessageId.make(`message:${input.runId}`), + rootNodeId: null, + activeAttemptId: null, + status: input.status, + requestedAt: input.now, + startedAt: input.now, + completedAt: input.status === "running" ? null : input.now, + checkpointId: null, + contextHandoffId: null, + ...(input.delegatedCompletion === undefined + ? {} + : { delegatedCompletion: input.delegatedCompletion }), + }, +}); + +/** A running app-owned task whose child thread's first run the restart cancelled. */ +const seedRestartCancelledChild = (input: { + readonly parentThreadId: ThreadId; + readonly projectId: ProjectId; + readonly parentRunId: RunId; + readonly rootNodeId: NodeId; + readonly name: string; + readonly completionWake: "always" | "settled_only"; + readonly continuationPending: boolean; + /** "completed" seeds a settled turn whose background work the restart cancelled. */ + readonly runStatus?: "cancelled" | "completed"; + readonly now: DateTime.Utc; +}) => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const taskId = NodeId.make(`node:${input.name}`); + const childThreadId = ThreadId.make(`thread:${input.name}`); + const childRunId = RunId.make(`run:${input.name}:1`); + yield* eventSink.write({ + commandId: CommandId.make(`command:seed-child:${input.name}`), + events: [ + { + id: EventId.make(`event:${input.name}:thread`), + type: "thread.created", + threadId: childThreadId, + occurredAt: input.now, + payload: { + createdBy: "agent", + creationSource: "server", + id: childThreadId, + projectId: input.projectId, + title: input.name, + providerInstanceId: modelSelection.instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + parentThreadId: input.parentThreadId, + relationshipToParent: "subagent", + rootThreadId: input.parentThreadId, + }, + forkedFrom: { type: "node", nodeId: taskId }, + createdAt: input.now, + updatedAt: input.now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }, + { + id: EventId.make(`event:${input.name}:task`), + type: "subagent.updated", + threadId: input.parentThreadId, + runId: input.parentRunId, + nodeId: taskId, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: input.now, + payload: { + id: taskId, + threadId: input.parentThreadId, + runId: input.parentRunId, + parentNodeId: input.rootNodeId, + origin: "app_owned", + createdBy: "agent", + driver, + providerInstanceId: modelSelection.instanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: `Run ${input.name}.`, + title: null, + model: null, + completionWake: input.completionWake, + status: "running", + result: null, + startedAt: input.now, + completedAt: null, + updatedAt: input.now, + }, + }, + ], + }); + yield* eventSink.writeWithEffects({ + commandId: reconcileCommandId(input.name), + events: [ + runEvent({ + threadId: childThreadId, + runId: childRunId, + ordinal: 1, + status: input.runStatus ?? "cancelled", + now: input.now, + }), + ], + effects: input.continuationPending + ? [ + { + id: `effect:restart-continuation:${childRunId}`, + commandId: reconcileCommandId(input.name), + threadId: childThreadId, + request: { type: "provider-runtime.continue", sourceRunId: childRunId }, + }, + ] + : [], + }); + return { taskId, childThreadId, childRunId }; + }); + +it.layer(TestLayer)("delegated tasks across a server restart", (it) => { + it.effect("holds a restart-cancelled child for its continuation's result", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:restart-parent"); + const projectId = ProjectId.make("project:restart-parent"); + const runId = RunId.make("run:restart-parent"); + const rootNodeId = NodeId.make("node:restart-parent-root"); + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId, + rootNodeId, + taskId: NodeId.make("node:restart-parent-settled"), + deliveryState: "delivered", + now, + }); + const child = ( + name: string, + continuationPending: boolean, + runStatus?: "cancelled" | "completed", + ) => + seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name, + completionWake: "always", + continuationPending, + ...(runStatus === undefined ? {} : { runStatus }), + now, + }); + const resumed = yield* child("restart-resumed-child", true); + const stopped = yield* child("restart-stopped-child", false); + // Settled with only background work left: its interim reply is not the result. + const backgrounded = yield* child("restart-backgrounded-child", true, "completed"); + // A second restart cut the first continuation before it started. + const recut = yield* child("restart-recut-child", false); + const recutContinuationId = RunId.make("run:restart-recut-child:2"); + const recutCommandId = CommandId.make("command:restart-recut-child:reconcile"); + const recutRun = runEvent({ + threadId: recut.childThreadId, + runId: recutContinuationId, + ordinal: 2, + status: "cancelled", + now, + }); + yield* eventSink.writeWithEffects({ + commandId: recutCommandId, + events: [ + { + ...recutRun, + payload: { + ...recutRun.payload, + startedAt: null, + restartContinuationOfRunId: recut.childRunId, + }, + }, + ], + effects: [ + { + id: `effect:restart-continuation:${recutContinuationId}`, + commandId: recutCommandId, + threadId: recut.childThreadId, + request: { type: "provider-runtime.continue", sourceRunId: recutContinuationId }, + }, + ], + }); + + yield* orchestrator.recoverDelegatedTasks; + + const recovered = yield* orchestrator.getThreadProjection(threadId); + const task = (id: NodeId) => recovered.subagents.find((row) => row.id === id); + assert.equal(task(stopped.taskId)?.status, "cancelled"); + assert.equal(task(stopped.taskId)?.completionDelivery?.state, "claimed"); + assert.equal(task(resumed.taskId)?.status, "running"); + assert.isNull(task(resumed.taskId)?.result ?? null); + assert.equal(task(backgrounded.taskId)?.status, "running"); + assert.isNull(task(backgrounded.taskId)?.result ?? null); + assert.equal(task(recut.taskId)?.status, "running"); + assert.isTrue(yield* orchestrator.delegatedTaskResultPending(recut.childThreadId)); + assert.isTrue(yield* orchestrator.delegatedTaskResultPending(resumed.childThreadId)); + assert.isFalse(yield* orchestrator.delegatedTaskResultPending(stopped.childThreadId)); + // A replayed first continuation settling must not release the second one's hold. + yield* orchestrator.recoverDelegatedTask(recut.childThreadId, recut.childRunId); + const replayed = yield* orchestrator.getThreadProjection(threadId); + assert.equal(replayed.subagents.find((row) => row.id === recut.taskId)?.status, "running"); + // A caller that read the cancelled run before the child resumed sees it as pending. + yield* eventSink.write({ + commandId: CommandId.make("command:restart-stopped-child:resumed"), + events: [ + runEvent({ + threadId: stopped.childThreadId, + runId: RunId.make("run:restart-stopped-child:2"), + ordinal: 2, + status: "running", + now, + }), + ], + }); + assert.isTrue(yield* orchestrator.delegatedTaskResultPending(stopped.childThreadId)); + assert.isFalse( + recovered.contextTransfers.some( + (transfer) => transfer.sourceThreadId === resumed.childThreadId, + ), + ); + + // The continuation's own run finishing settles the task with its result. + const afterSequence = yield* eventSink.latestSequence(); + const continuationRunId = RunId.make("run:restart-resumed-child:2"); + yield* eventSink.write({ + commandId: CommandId.make("command:restart-resumed-child:completed"), + events: [ + { + id: EventId.make("event:restart-resumed-child:result"), + type: "message.updated", + threadId: resumed.childThreadId, + runId: continuationRunId, + occurredAt: now, + payload: { + id: MessageId.make("message:restart-resumed-child:result"), + threadId: resumed.childThreadId, + runId: continuationRunId, + nodeId: null, + role: "assistant", + text: "Finished after the restart.", + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + createdAt: now, + updatedAt: now, + }, + }, + runEvent({ + threadId: resumed.childThreadId, + runId: continuationRunId, + ordinal: 2, + status: "completed", + now, + }), + ], + }); + const settled = yield* eventSink + .stream({ afterSequence, eventType: "subagent.updated" }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.id === resumed.taskId, + ), + Stream.take(1), + Stream.runHead, + ); + assert.isTrue(settled._tag === "Some"); + const finished = yield* orchestrator.getThreadProjection(threadId); + const finishedTask = finished.subagents.find((row) => row.id === resumed.taskId); + assert.equal(finishedTask?.status, "completed"); + assert.equal(finishedTask?.result, "Finished after the restart."); + }), + ); + + it.effect("settles a restart-cancelled child whose continuation declines to start", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:restart-declined-parent"); + const projectId = ProjectId.make("project:restart-declined-parent"); + const runId = RunId.make("run:restart-declined-parent"); + const rootNodeId = NodeId.make("node:restart-declined-parent-root"); + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId, + rootNodeId, + taskId: NodeId.make("node:restart-declined-parent-settled"), + deliveryState: "delivered", + now, + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name: "restart-declined-child", + completionWake: "always", + continuationPending: true, + now, + }); + yield* orchestrator.recoverDelegatedTasks; + const held = yield* orchestrator.getThreadProjection(threadId); + assert.equal(held.subagents.find((row) => row.id === child.taskId)?.status, "running"); + + yield* continueRestartedRun({ + threadId: child.childThreadId, + sourceRunId: child.childRunId, + }).pipe( + Effect.provide(ServerSettings.layerTest({ continueThreadsAfterServerUpdate: false })), + ); + + const settled = yield* orchestrator.getThreadProjection(threadId); + const task = settled.subagents.find((row) => row.id === child.taskId); + assert.equal(task?.status, "cancelled"); + assert.equal(task?.completionDelivery?.state, "claimed"); + }), + ); + + // A running provider turn does not prove the provider consumed the delivery + // (Claude marks the turn running before it reads the prompt), so a cut + // delivery is offered again even when a continuation resumes that turn. + it.effect("re-offers a cut delivery even when a continuation resumes its turn", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const seed = (name: string, continuationPending: boolean) => + Effect.gen(function* () { + const threadId = ThreadId.make(`thread:${name}`); + const runId = RunId.make(`run:${name}`); + const taskId = NodeId.make(`node:${name}-task`); + const messageId = MessageId.make(`message:delegated-delivery:${threadId}`); + const deliveryRunId = RunId.make(`run:${name}:delivery`); + yield* seedParentWithTerminalTask({ + threadId, + projectId: ProjectId.make(`project:${name}`), + runId, + rootNodeId: NodeId.make(`node:${name}-root`), + taskId, + deliveryState: "claimed", + completionWake: "always", + deliveryTaskIds: [taskId], + now, + }); + yield* eventSink.writeWithEffects({ + commandId: reconcileCommandId(name), + events: [ + { + id: EventId.make(`event:${name}:delivery-message`), + type: "message.updated", + threadId, + runId: deliveryRunId, + occurredAt: now, + payload: { + id: messageId, + threadId, + runId: deliveryRunId, + nodeId: null, + role: "user", + text: `Delegated task ${taskId} reached a terminal state.`, + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + createdAt: now, + updatedAt: now, + delegatedCompletion: { parentRunId: runId, generation: 1, taskIds: [taskId] }, + }, + }, + runEvent({ + threadId, + runId: deliveryRunId, + ordinal: 2, + status: "cancelled", + now, + providerThreadId: parentProviderThreadId(threadId), + userMessageId: messageId, + }), + ], + effects: continuationPending + ? [ + { + id: `effect:restart-continuation:${deliveryRunId}`, + commandId: reconcileCommandId(name), + threadId, + request: { type: "provider-runtime.continue", sourceRunId: deliveryRunId }, + }, + ] + : [], + }); + return { threadId, runId, taskId }; + }); + const cut = yield* seed("delivery-cut", false); + const resumed = yield* seed("delivery-resumed", true); + + yield* orchestrator.recoverDelegatedTasks; + + for (const seeded of [cut, resumed]) { + const projection = yield* orchestrator.getThreadProjection(seeded.threadId); + assert.deepEqual( + projection.subagents.find((row) => row.id === seeded.taskId)?.completionDelivery?.state, + "claimed", + ); + const delivery = projection.runs.find((row) => row.id === seeded.runId)?.delegatedCompletion + ?.delivery; + assert.equal(delivery?.generation, 2); + assert.deepEqual(delivery?.taskIds, [seeded.taskId]); + } + }), + ); + + it.effect("wakes the parent for a settled_only task once its spawning turn ended", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:settled-only-restart"); + const projectId = ProjectId.make("project:settled-only-restart"); + const runId = RunId.make("run:settled-only-restart"); + const rootNodeId = NodeId.make("node:settled-only-restart-root"); + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId, + rootNodeId, + taskId: NodeId.make("node:settled-only-restart-settled"), + deliveryState: "delivered", + now, + }); + // The restart cut the turn blocked in delegate_task(wait); its + // continuation is a new, live run. + yield* eventSink.write({ + commandId: reconcileCommandId("settled-only-restart"), + events: [ + runEvent({ + threadId, + runId, + ordinal: 1, + status: "cancelled", + now, + providerThreadId: parentProviderThreadId(threadId), + userMessageId: MessageId.make(`message:seed-user:${threadId}`), + delegatedCompletion: { disposition: "open", nextGeneration: 2, delivery: null }, + }), + runEvent({ + threadId, + runId: RunId.make("run:settled-only-restart:continuation"), + ordinal: 2, + status: "running", + now, + providerThreadId: parentProviderThreadId(threadId), + }), + ], + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name: "settled-only-restart-child", + completionWake: "settled_only", + continuationPending: false, + now, + }); + + yield* orchestrator.recoverDelegatedTasks; + + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + projection.subagents.find((row) => row.id === child.taskId)?.completionDelivery?.state, + "claimed", + ); + assert.deepEqual( + projection.runs.find((row) => row.id === runId)?.delegatedCompletion?.delivery?.taskIds, + [child.taskId], + ); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index af2667a8cf8e..5f7db32b1194 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -79,6 +79,8 @@ function restartEffect( function makeExecutorLayer(input: { readonly events: Ref.Ref>; readonly failFirstStart?: Ref.Ref; + readonly threads?: Partial; + readonly continueAfterRestart?: boolean; }) { const record = (event: string) => Ref.update(input.events, (events) => [...events, event]); const dependencies = Layer.mergeAll( @@ -148,8 +150,10 @@ function makeExecutorLayer(input: { Layer.provide( Layer.mergeAll( dependencies, - Layer.mock(ThreadManagementService.ThreadManagementService)({}), - ServerSettings.layerTest(), + Layer.mock(ThreadManagementService.ThreadManagementService)(input.threads ?? {}), + ServerSettings.layerTest( + input.continueAfterRestart === true ? { continueThreadsAfterServerUpdate: true } : {}, + ), ), ), ); @@ -753,3 +757,46 @@ it.effect("safely retries after replacement cleanup succeeds and start fails", ( ]); }), ); + +it.effect("settles a delegated child once its restart continuation fails for good", () => + Effect.gen(function* () { + const timestamp = DateTime.formatIso(yield* DateTime.now); + const events = yield* Ref.make>([]); + const recovered = yield* Ref.make>([]); + const layer = makeExecutorLayer({ + events, + continueAfterRestart: true, + threads: { + getThreadRecords: () => Effect.fail(new Error("provider instance removed") as never), + recoverDelegatedTask: (childThreadId) => + Ref.update(recovered, (ids) => [...ids, childThreadId]), + }, + }); + const effect: EffectOutbox.OrchestrationEffectV2 = { + id: `effect:restart-continuation:${runId}`, + commandId: CommandId.make("command:restart-continuation-failure"), + threadId, + request: { type: "provider-runtime.continue", sourceRunId: runId }, + status: "running", + attemptCount: 1, + availableAt: timestamp, + leaseOwner: "test-worker", + leaseExpiresAt: timestamp, + createdAt: timestamp, + updatedAt: timestamp, + completedAt: null, + lastError: null, + }; + yield* Effect.gen(function* () { + const executor = yield* EffectWorker.OrchestrationEffectExecutorV2; + assert.isTrue( + Exit.isFailure(yield* Effect.exit(executor.execute(effect, { willRetry: true }))), + ); + assert.deepEqual(yield* Ref.get(recovered), []); + assert.isTrue( + Exit.isFailure(yield* Effect.exit(executor.execute(effect, { willRetry: false }))), + ); + assert.deepEqual(yield* Ref.get(recovered), [threadId]); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 1abf070b87ec..149494739182 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -108,13 +108,17 @@ export const executorLayer: Layer.Layer< execute: (effect, options) => { const willRetry = options?.willRetry ?? false; switch (effect.request.type) { - case "provider-runtime.continue": - return continueRestartedRun({ - threadId: effect.threadId, - sourceRunId: effect.request.sourceRunId, - }).pipe( + case "provider-runtime.continue": { + const sourceRunId = effect.request.sourceRunId; + return continueRestartedRun({ threadId: effect.threadId, sourceRunId }).pipe( Effect.provideService(ThreadManagementService.ThreadManagementService, threads), Effect.provideService(ServerSettings.ServerSettingsService, settings), + // A continuation that will never run still owes a delegated parent a result. + Effect.tapError(() => + willRetry + ? Effect.void + : threads.recoverDelegatedTask(effect.threadId, sourceRunId), + ), Effect.mapError( (cause) => new OrchestrationEffectExecutionError({ @@ -124,6 +128,7 @@ export const executorLayer: Layer.Layer< }), ), ); + } case "provider-session.detach": return providerSessions .detach({ @@ -169,10 +174,12 @@ export const executorLayer: Layer.Layer< // The provider has stopped what it still ran and reported it. // Whatever the thread still shows on that provider thread is // work no process will report on, so the Stop ends it too. + // One Stop can interrupt several provider threads, so the + // settle is keyed by effect, not by the Stop command. Effect.andThen( threads.dispatch({ type: "thread.background-work.settle", - commandId: CommandId.make(`${effect.commandId}:background-work-settled`), + commandId: CommandId.make(`${effect.id}:background-work-settled`), threadId: effect.threadId, providerThreadId: effect.request.providerThreadId, providerTurnId: effect.request.providerTurnId, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 2707fe5b8344..42c55371d5f7 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1,6 +1,7 @@ import { latestExecutedRun, latestRootProviderFailure, + runRanAfter, usageLimitBlockedRun, } from "@t3tools/shared/orchestrationV2ThreadError"; import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; @@ -11,6 +12,7 @@ import { legacyThreadPullRequestKey, } from "@t3tools/shared/threadPullRequests"; import { + ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, type ChatAttachment, CommandId, isProviderNativeSubagentThread, @@ -77,6 +79,7 @@ import { notificationTurnItem } from "./Notification.ts"; import { isRestartNoteSource } from "./RestartBackgroundNote.ts"; import { isUndeliveredMailboxSteer } from "./NotificationMailbox.ts"; import { EventSinkV2 } from "./EventSink.ts"; +import * as EffectOutbox from "./EffectOutbox.ts"; import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; import { IdAllocatorV2 } from "./IdAllocator.ts"; import { @@ -242,6 +245,17 @@ export interface OrchestratorV2DispatchResult { export interface OrchestratorV2Shape { readonly resumeQueuedRuns: Effect.Effect; + /** Startup pass that settles delegated-task results and deliveries runs left behind. */ + readonly recoverDelegatedTasks: Effect.Effect; + /** Settles a delegated child whose restart continuation of `sourceRunId` declined or failed. */ + readonly recoverDelegatedTask: (threadId: ThreadId, sourceRunId: RunId) => Effect.Effect; + /** + * Whether a delegated child's apparent result is not final yet: a restart + * continuation is pending, or the child is working again. + */ + readonly delegatedTaskResultPending: ( + childThreadId: ThreadId, + ) => Effect.Effect; readonly dispatch: ( command: OrchestrationV2ServerCommand, ) => Effect.Effect; @@ -338,7 +352,8 @@ function wakeWorkStartedAt( return previous === undefined ? {} : { workStartedAt: orchestrationV2RunWorkStartedAt(previous) }; } -function isNativeMaintenanceCommand(message: { +/** A native /compact or /logout turn: provider maintenance, not agent work. */ +export function isNativeMaintenanceCommand(message: { readonly text: string; readonly attachments: ReadonlyArray; readonly context?: import("@t3tools/contracts").OrchestrationMessageContext | undefined; @@ -386,6 +401,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "prepared-run.release": case "prepared-run.progress": case "prepared-run.fail": + case "prepared-run.retry": case "run.interrupt": case "queued-message.promote-to-steer": case "queue.resume": @@ -428,6 +444,14 @@ function pendingThreadTitleGenerationEffect( const WORKSPACE_PREPARATION_INPUT = "Preparing workspace"; +/** A reopened preparation item drops the output and exit code of the attempt it replaces. */ +function withoutPreparationResult( + item: Extract, +) { + const { output: _output, exitCode: _exitCode, outputIndicatesFailure: _failure, ...rest } = item; + return rest; +} + function isBlockingRun(run: OrchestrationV2Run): boolean { return ( run.status === "preparing" || @@ -718,6 +742,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const idAllocator = yield* IdAllocatorV2; const projects = yield* ProjectStore.ProjectStoreV2; const projectionStore = yield* ProjectionStoreV2; + const effectOutbox = yield* EffectOutbox.EffectOutboxV2; const nextTurnItemOrdinal = ( projection: Pick & Partial>, @@ -4357,7 +4382,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projection.thread.archivedAt !== null || projection.thread.deletedAt !== null || projection.thread.providerInstanceId !== source.providerInstanceId || - projection.runs.some((run) => run.ordinal > source.ordinal) + // Held queued runs never started; they wait behind the continuation. + projection.runs.some( + (run) => run.id !== source.id && run.status !== "queued" && runRanAfter(run, source), + ) ) { // Preserve the current row so stale automatic deliveries receive an // accepted receipt without changing work or repeatedly retrying. @@ -5132,6 +5160,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.restartContinuationOfRunId === undefined ? {} : { restartContinuationOfRunId: command.restartContinuationOfRunId }), + ...(dispatchMode.type === "defer_start" && dispatchMode.workspaceStrategy !== undefined + ? { workspacePreparation: dispatchMode.workspaceStrategy } + : {}), ...wakeWorkStartedAt(projection.runs, command), }; const attempt: OrchestrationV2RunAttempt = { @@ -6594,12 +6625,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // policy when the child terminalizes. Both writers hold this parent lock, // so a terminal task means finalize already committed the terminal row and // already made its offer decision under the pre-upgrade policy: under - // settled_only it offered iff the parent had no live run. Plan a delivery - // only when the parent has a live run now, which is precisely the case + // settled_only it offered iff the spawning run was not live. Plan a + // delivery only when that run is live now, which is precisely the case // where finalize skipped. The mailbox steers a capable active session or - // queues behind that run. When the parent is not live, finalize already - // offered and a second - // offer would wake the parent twice. (If the parent settled in between, + // queues behind that run. When that run is not live, finalize already + // offered and a second offer would wake the parent twice. (If the parent settled in between, // this skips a wake that finalize also skipped; a missed wake is cheaper // than a duplicate one, and the result is already in the projection.) const parentRun = @@ -6609,7 +6639,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const completionPlan = command.completionWake === "always" && isTerminalDelegatedTaskStatus(task.status) && - hasLiveRun(parentProjection) + parentRun !== undefined && + hasLiveRun({ runs: [parentRun] }) ? yield* planDelegatedCompletionDelivery({ parentProjection, parentRun, @@ -7471,7 +7502,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command: Extract< OrchestrationV2Command, { - readonly type: "prepared-run.release" | "prepared-run.progress" | "prepared-run.fail"; + readonly type: + | "prepared-run.release" + | "prepared-run.progress" + | "prepared-run.fail" + | "prepared-run.retry"; } >, projection: Pick< @@ -7494,7 +7529,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio candidate.input === WORKSPACE_PREPARATION_INPUT, ); if ( - run?.status !== "preparing" || + run?.status !== (command.type === "prepared-run.retry" ? "failed" : "preparing") || attempt === undefined || rootNode === undefined || providerThread === undefined || @@ -7712,7 +7747,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio completedAt: now, updatedAt: now, type: "error", - failure: command.failure, + failure: { + ...command.failure, + code: ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, + }, }, }); yield* emitEvent({ @@ -7726,6 +7764,90 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); + /** + * Returns a run whose workspace preparation failed to preparing. The failure + * item turns cancelled so clients stop offering the retry; ThreadLaunchService + * runs the recorded preparation again once this commits. + */ + const dispatchPreparedRunRetry = ( + command: Extract, + events: Ref.Ref>, + ) => + Effect.gen(function* () { + const projection = yield* loadProjectionForCommand( + command, + ["runs", "attempts", "nodes", "providerThreads", "turnItems"], + { turnItemTypes: ["command_execution", "error"], turnItemRunId: command.runId }, + ); + const state = preparedRunState(command, projection); + const failureItem = projection.turnItems.find( + (candidate): candidate is Extract => + candidate.type === "error" && + candidate.status === "failed" && + candidate.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, + ); + if ( + state === null || + state.run.workspacePreparation === undefined || + failureItem === undefined || + projection.thread.archivedAt !== null || + projection.thread.deletedAt !== null + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Run ${command.runId} has no failed workspace preparation to retry.`, + }); + } + if (projection.runs.some((run) => run.id !== state.run.id && isBlockingRun(run))) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Another run is active on this thread.", + }); + } + const now = yield* DateTime.now; + const emitEvent = emit(events, command); + const scope = { + threadId: command.threadId, + runId: state.run.id, + nodeId: state.rootNode.id, + providerInstanceId: state.run.providerInstanceId, + occurredAt: now, + }; + yield* emitEvent({ + ...scope, + type: "turn-item.updated", + payload: { ...failureItem, status: "cancelled", updatedAt: now }, + }); + yield* emitEvent({ + ...scope, + type: "turn-item.updated", + payload: { + ...withoutPreparationResult(state.preparationItem), + status: "running", + title: WORKSPACE_PREPARATION_INPUT, + completedAt: null, + updatedAt: now, + }, + }); + yield* emitEvent({ + ...scope, + type: "run-attempt.updated", + payload: { ...state.attempt, status: "pending", completedAt: null }, + }); + yield* emitEvent({ + ...scope, + type: "node.updated", + payload: { ...state.rootNode, status: "pending", completedAt: null }, + }); + yield* emitEvent({ + ...scope, + type: "run.updated", + payload: { ...state.run, status: "preparing", completedAt: null }, + }); + }); + /** * Ends the background work a settled thread still shows that no provider * process will report on: work on the provider thread whose interrupt just @@ -8108,6 +8230,63 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Run ${command.runId} is not interruptible.`, }); } + // Background work can outlive a provider switch, such as a Codex dev + // server left running when the thread moved to Claude. Stop also reaches + // each other live provider thread that owns pending work; that + // interrupt's settle follow-up ends what its provider leaves behind. + // Work on a dead session is settled with this run's below. + const otherProviderInterrupts: Array = []; + if (hasBackgroundWork) { + const runOrdinals = new Map( + projection.runs.map((candidate) => [candidate.id, candidate.ordinal]), + ); + const runOrdinalOf = (item: (typeof projection.turnItems)[number]) => + item.runId === null ? -1 : (runOrdinals.get(item.runId) ?? -1); + // Each provider thread is interrupted at its latest pending work: that + // turn's run bounds the settle follow-up, which must cover all of the + // thread's work. The target comes from the item's provider turn, since + // a native subagent item names its own provider thread but its + // parent's turn; interrupting the parent's turn reaches the subagent. + const latestTurnByProviderThread = new Map< + OrchestrationV2ProviderThread["id"], + { readonly turn: (typeof projection.providerTurns)[number]; readonly runOrdinal: number } + >(); + for (const item of pendingBackgroundTurnItems({ + turnItems: projection.turnItems, + runs: projection.runs, + })) { + const turn = projection.providerTurns.find( + (candidate) => candidate.id === item.providerTurnId, + ); + if (turn === undefined || turn.providerThreadId === providerThread.id) continue; + const runOrdinal = runOrdinalOf(item); + const latest = latestTurnByProviderThread.get(turn.providerThreadId); + if (latest === undefined || runOrdinal > latest.runOrdinal) { + latestTurnByProviderThread.set(turn.providerThreadId, { turn, runOrdinal }); + } + } + for (const { turn } of latestTurnByProviderThread.values()) { + const owner = projection.providerThreads.find( + (candidate) => candidate.id === turn.providerThreadId, + ); + if (owner === undefined || owner.providerSessionId === null) continue; + const ownerSession = yield* providerSessions + .get(owner.providerSessionId) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isNone(ownerSession)) continue; + otherProviderInterrupts.push({ + id: `effect:${command.commandId}:provider-turn.interrupt:${turn.id}`, + commandId: command.commandId, + threadId: command.threadId, + request: { + type: "provider-turn.interrupt", + providerSessionId: owner.providerSessionId, + providerThreadId: owner.id, + providerTurnId: turn.id, + }, + }); + } + } // Stop on a settled thread's background work. Its process may be gone // (released, restarted) and only the projection still shows the work; // the settle follow-up ends whatever no provider reports ending. @@ -8139,6 +8318,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio throughRunOrdinal: run.ordinal, now, }); + yield* Ref.update(effects, (existing) => [...existing, ...otherProviderInterrupts]); return undefined; } if (providerThread.providerSessionId === null) { @@ -8200,6 +8380,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio providerTurnId: providerTurn.id, }, } satisfies PendingOrchestrationEffectV2, + ...otherProviderInterrupts, ]); return undefined; }); @@ -8435,6 +8616,39 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : undefined; }); + /** + * A run with a pending restart continuation has no outcome yet: restart + * reconciliation cancelled it mid-turn, or cancelled the background work a + * settled turn was waiting on. The continuation's own run settles it, or + * RestartContinuation recovers the thread when it declines to continue. + */ + const awaitsRestartContinuation = (run: OrchestrationV2Run) => + effectOutbox + .get(`effect:restart-continuation:${run.id}`) + .pipe( + Effect.map( + Option.exists((effect) => effect.status === "pending" || effect.status === "running"), + ), + ); + + /** + * A delegated child's result is held while its result run, or a run after it + * that a second restart cut before it started, still has a continuation pending. + */ + const childAwaitsRestartContinuation = ( + runs: ReadonlyArray, + resultRun: OrchestrationV2Run, + settledContinuationOf?: RunId, + ) => + Effect.forEach( + runs.filter( + (run) => + run.id !== settledContinuationOf && + (run.id === resultRun.id || runRanAfter(run, resultRun)), + ), + awaitsRestartContinuation, + ).pipe(Effect.map((pending) => pending.includes(true))); + const planDelegatedCompletionDelivery = Effect.fn( "orchestrationV2.planDelegatedCompletionDelivery", )(function* (input: { @@ -8482,9 +8696,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio offer: false, }; } + // settled_only defers to a blocking delegate_task wait, which lives only as + // long as the turn that spawned the task. Once that turn is over (a restart + // continues it as a new run), nothing else delivers the result. if ( (input.task.completionWake ?? "settled_only") === "settled_only" && - hasLiveRun(input.parentProjection) + hasLiveRun({ runs: [input.parentRun] }) ) { return { task: input.updatedTask, @@ -8635,9 +8852,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio * delegated_task.wake-policy handler rewrites the same subagent row under * that lock with a full-row payload, and unserialized writers clobber each * other (stale policy on the terminal row, or a terminal row regressed to - * running). + * running). `settledContinuationOf` names the restart continuation that just + * declined or failed, so its own still-running effect does not hold the child. */ - const finalizeAppOwnedSubagent = (childThreadId: ThreadId) => + const finalizeAppOwnedSubagent = ( + childThreadId: ThreadId, + options?: { readonly settledContinuationOf?: RunId }, + ) => Effect.gen(function* () { const childControls = yield* projectionStore.getThreadRecords( childThreadId, @@ -8660,6 +8881,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (terminalStatus === null) { return; } + if ( + yield* childAwaitsRestartContinuation( + childControls.runs, + childRun, + options?.settledContinuationOf, + ) + ) { + return; + } const childResult = yield* projectionStore.getThreadRecords( childThreadId, @@ -9088,7 +9318,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); return; } - const parentIsLive = hasLiveRun(projection); + // settled_only tasks defer only to a blocking wait in their spawning run. + const parentIsLive = hasLiveRun({ runs: [parentRun] }); const pendingTaskIds = projection.thread.archivedAt === null && projection.thread.deletedAt === null ? projection.subagents @@ -9323,6 +9554,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "prepared-run.fail": yield* dispatchPreparedRunFail(command, events); break; + case "prepared-run.retry": + yield* dispatchPreparedRunRetry(command, events); + break; case "runtime-request.respond": yield* dispatchRuntimeRequestRespond(command, events, effects); break; @@ -9690,97 +9924,140 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Effect.forkDetach, ); - // Recover child results from projections. Queue recovery instead holds - // unstarted runs until an explicit queue.resume command arrives. - yield* projectionStore.getRecoveryThreadIds("subagent-results").pipe( - Effect.flatMap((threadIds) => - Effect.forEach( - threadIds, - (threadId) => - Effect.gen(function* () { - const thread = yield* projectionStore.getThreadShell(threadId); - const parentThreadId = thread?.lineage.parentThreadId; - if (parentThreadId === undefined || parentThreadId === null) return; - yield* threadDispatch.withLock(parentThreadId, finalizeAppOwnedSubagent(threadId)); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Failed to recover terminal app-owned subagent", { - childThreadId: threadId, - cause, - }), - ), - ), - { concurrency: 8, discard: true }, - ), - ), - Effect.catchCause((cause) => - Effect.logWarning("Failed to inspect app-owned subagents during recovery", { - cause, - }), - ), - ); - yield* projectionStore.getRecoveryThreadIds("delegated-completions").pipe( - Effect.flatMap((threadIds) => - Effect.forEach( - threadIds, - (threadId) => - threadDispatch - .withLock( - threadId, - Effect.gen(function* () { - const projection = yield* projectionStore.getThreadRecords(threadId, [ - "runs", - "messages", - ]); - const terminalDeliveryRunIds = projection.runs - .filter((run) => delegatedTaskTerminalStatus(run.status) !== null) - .filter((run) => - projection.messages.some( - (message) => - message.id === run.userMessageId && - message.delegatedCompletion !== undefined, - ), - ) - .map((run) => run.id); - for (const runId of terminalDeliveryRunIds) { - yield* finalizeDelegatedCompletionDelivery(threadId, runId); - } - const refreshed = - terminalDeliveryRunIds.length === 0 - ? projection - : yield* projectionStore.getThreadRecords(threadId, ["runs", "messages"], { - messageRoles: ["user"], - }); - for (const run of refreshed.runs) { - if ( - run.delegatedCompletion?.delivery !== null && - run.delegatedCompletion !== undefined - ) { - yield* offerDelegatedCompletionDelivery(threadId, run.id); - } - } - }), - ) - .pipe( + // Settles child results and completion deliveries whose runs ended without + // the listener above: before this boot, or in runtime reconciliation, which + // it skips. Startup runs this after reconciliation and before the effect + // worker. Queue recovery instead holds unstarted runs until an explicit + // queue.resume command arrives. + const recoverDelegatedTasks = Effect.gen(function* () { + yield* projectionStore.getRecoveryThreadIds("subagent-results").pipe( + Effect.flatMap((threadIds) => + Effect.forEach( + threadIds, + (threadId) => + Effect.gen(function* () { + const thread = yield* projectionStore.getThreadShell(threadId); + const parentThreadId = thread?.lineage.parentThreadId; + if (parentThreadId === undefined || parentThreadId === null) return; + yield* threadDispatch.withLock(parentThreadId, finalizeAppOwnedSubagent(threadId)); + }).pipe( Effect.catchCause((cause) => - Effect.logWarning("Failed to recover delegated completion delivery", { - threadId, + Effect.logWarning("Failed to recover terminal app-owned subagent", { + childThreadId: threadId, cause, }), ), ), - { concurrency: 8, discard: true }, + { concurrency: 8, discard: true }, + ), ), - ), - Effect.catchCause((cause) => - Effect.logWarning("Failed to inspect delegated completion delivery during recovery", { - cause, - }), - ), - ); + Effect.catchCause((cause) => + Effect.logWarning("Failed to inspect app-owned subagents during recovery", { + cause, + }), + ), + ); + yield* projectionStore.getRecoveryThreadIds("delegated-completions").pipe( + Effect.flatMap((threadIds) => + Effect.forEach( + threadIds, + (threadId) => + threadDispatch + .withLock( + threadId, + Effect.gen(function* () { + const projection = yield* projectionStore.getThreadRecords(threadId, [ + "runs", + "messages", + ]); + const terminalDeliveryRunIds = projection.runs + .filter((run) => delegatedTaskTerminalStatus(run.status) !== null) + .filter((run) => + projection.messages.some( + (message) => + message.id === run.userMessageId && + message.delegatedCompletion !== undefined, + ), + ) + .map((run) => run.id); + for (const runId of terminalDeliveryRunIds) { + yield* finalizeDelegatedCompletionDelivery(threadId, runId); + } + const refreshed = + terminalDeliveryRunIds.length === 0 + ? projection + : yield* projectionStore.getThreadRecords(threadId, ["runs", "messages"], { + messageRoles: ["user"], + }); + for (const run of refreshed.runs) { + if ( + run.delegatedCompletion?.delivery !== null && + run.delegatedCompletion !== undefined + ) { + yield* offerDelegatedCompletionDelivery(threadId, run.id); + } + } + }), + ) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to recover delegated completion delivery", { + threadId, + cause, + }), + ), + ), + { concurrency: 8, discard: true }, + ), + ), + Effect.catchCause((cause) => + Effect.logWarning("Failed to inspect delegated completion delivery during recovery", { + cause, + }), + ), + ); + }); + + const delegatedTaskResultPending = (childThreadId: ThreadId) => + Effect.gen(function* () { + const child = yield* projectionStore.getThreadRecords( + childThreadId, + ["runs", "messages", "subagents", "providerThreads", "providerTurns", "attempts"], + { messageRoles: ["user"] }, + ); + const progress = delegatedTaskProgress(child); + // A caller's older read saw a result; newer work since then means it is not final. + if (progress.state !== "result_available") return true; + if (progress.resultRun === undefined) return false; + return yield* childAwaitsRestartContinuation(child.runs, progress.resultRun); + }).pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: childThreadId, cause }), + ), + ); + + const recoverDelegatedTask = (threadId: ThreadId, sourceRunId: RunId) => + Effect.gen(function* () { + const parentThreadId = yield* appOwnedSubagentParentThreadId(threadId); + if (parentThreadId === undefined) return; + yield* threadDispatch.withLock( + parentThreadId, + finalizeAppOwnedSubagent(threadId, { settledContinuationOf: sourceRunId }), + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to recover delegated task after restart", { + childThreadId: threadId, + cause, + }), + ), + ); return OrchestratorV2.of({ resumeQueuedRuns, + recoverDelegatedTasks, + recoverDelegatedTask, + delegatedTaskResultPending, dispatch: dispatchWithReceipt, getTimelinePage: (threadId, options) => projectionStore @@ -9874,6 +10151,7 @@ export const layer: Layer.Layer< | CommandPolicyV2 | CommandReceiptStoreV2 | ContextHandoffServiceV2 + | EffectOutbox.EffectOutboxV2 | EventSinkV2 | IdAllocatorV2 | ProjectStore.ProjectStoreV2 @@ -9897,6 +10175,9 @@ const layerUnavailable: Layer.Layer = Layer.succeed( cause: "Orchestration V2 live runtime is not configured.", }), ), + recoverDelegatedTasks: Effect.void, + recoverDelegatedTask: () => Effect.void, + delegatedTaskResultPending: () => Effect.succeed(false), dispatch: (command) => Effect.fail( new OrchestratorDispatchError({ diff --git a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts index 8db18a0d5f08..617655e38fb1 100644 --- a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts @@ -420,6 +420,73 @@ it.effect("includes shared sessions and provider-owned background rosters in rec }).pipe(Effect.provide(TestLayer)), ); +it.effect("reads the run that owns a background roster, not a queued or resumed one", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const withRoster = Effect.fn(function* (threadId: ThreadId) { + yield* projections.apply({ + id: EventId.make(`event:${threadId}:roster`), + type: "provider-thread.updated", + threadId, + driver, + providerInstanceId, + occurredAt: now, + payload: { + id: ProviderThreadId.make(`provider-thread:${threadId}`), + appThreadId: threadId, + ownerNodeId: null, + driver, + providerInstanceId, + providerSessionId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + pendingBackgroundTasks: [ + { taskId: "background", description: "Still running", kind: "command" }, + ], + }, + }); + }); + const runIds = (threadId: ThreadId) => + projections + .getRuntimeRecoveryProjection(threadId) + .pipe(Effect.map((state) => state.runs.map((run) => run.id))); + // Settled with background work left, then a queued follow-up. + const settled = yield* createThread("roster-before-queue"); + const settledRun = yield* createRun(settled, "completed", { + providerThreadId: ProviderThreadId.make(`provider-thread:${settled}`), + }); + const queuedRun = yield* createRun(settled, "queued", { ordinal: 2, completedAt: null }); + yield* withRoster(settled); + assert.deepEqual(yield* runIds(settled), [settledRun, queuedRun]); + // A resumed queued run (ordinal 1) ended after a continuation (ordinal 2). + const resumed = yield* createThread("roster-after-resume"); + const resumedRun = yield* createRun(resumed, "completed", { + providerThreadId: ProviderThreadId.make(`provider-thread:${resumed}`), + completedAt: DateTime.makeUnsafe("2026-10-03T10:05:00.000Z"), + }); + const continuationRun = yield* createRun(resumed, "completed", { + providerThreadId: ProviderThreadId.make(`provider-thread:${resumed}`), + ordinal: 2, + completedAt: DateTime.makeUnsafe("2026-10-03T10:00:00.000Z"), + }); + yield* withRoster(resumed); + assert.deepEqual(yield* runIds(resumed), [resumedRun, continuationRun]); + // Without a roster, settled history is not read. + const quiet = yield* createThread("no-roster-before-queue"); + yield* createRun(quiet, "completed"); + const quietQueued = yield* createRun(quiet, "queued", { ordinal: 2, completedAt: null }); + assert.deepEqual(yield* runIds(quiet), [quietQueued]); + }).pipe(Effect.provide(TestLayer)), +); + it.effect("marks fork descendants unreadable when their source is missing or corrupt", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index 60149fe8a292..1e8297c362c2 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -247,7 +247,7 @@ it.effect.each([ forkedFrom: null, createdAt: old, updatedAt: old, - pendingBackgroundTasks: [{ taskId: "running-task", kind: "command" }], + pendingBackgroundTasks: [{ taskId: "running-task", kind: "subagent" }], }, }); const candidates = yield* store.getSettlementCandidates(); @@ -278,7 +278,13 @@ it.effect.each([ }; assert.deepEqual( resolveAutoSettlementAt({ ...settings, thread: candidate }), - resolveAutoSettlementAt({ ...settings, thread: expected }), + resolveAutoSettlementAt({ + ...settings, + thread: { + ...expected, + latestUserAuthoredMessageAt: candidate.latestUserAuthoredMessageAt, + }, + }), ); } assert.equal( @@ -292,6 +298,54 @@ it.effect.each([ }).pipe(Effect.provide(testLayer)), ); +it.effect.each([ + ["sql", SqlLayer], + ["memory", ProjectionStore.layerMemory], +] as const)("%s: the user-authored message time ignores agent notifications", ([, testLayer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const threadId = yield* createThread("agent-woken"); + yield* createRun(threadId); + const message = ( + id: string, + createdBy: "user" | "agent", + creationSource: "web" | "provider", + at: DateTime.Utc, + ) => + store.apply({ + id: EventId.make(`event:settlement:${id}`), + type: "message.updated", + threadId, + occurredAt: at, + payload: { + createdBy, + creationSource, + id: MessageId.make(`message:settlement:${id}`), + threadId, + runId: null, + nodeId: null, + role: "user", + text: id, + attachments: [], + streaming: false, + createdAt: at, + updatedAt: at, + }, + }); + const written = DateTime.subtract(now, { hours: 2 }); + yield* message("written", "user", "web", written); + yield* message("notification", "agent", "provider", now); + + const [candidate] = yield* store.getSettlementCandidates(threadId); + assert.isDefined(candidate); + assert.equal(DateTime.formatIso(candidate.latestUserMessageAt!), DateTime.formatIso(now)); + assert.equal( + DateTime.formatIso(candidate.latestUserAuthoredMessageAt!), + DateTime.formatIso(written), + ); + }).pipe(Effect.provide(testLayer)), +); + const pullRequestLink = (number: number) => ({ host: "github.com", repository: "owner/repository", diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index c64fece9db07..3e979544c87b 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1377,6 +1377,36 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { (row) => row.sourceItemId === interruptResultId, ), ); + + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET payload_json = json_set(payload_json, '$.createdBy', 'agent') + WHERE thread_id = ${threadId} AND type = 'user_message' + `; + const agentPromptId = TurnItemId.make("turn-item:bounded-sql-history:interrupt-filler:1281"); + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET type = 'user_message', + payload_json = json_set(payload_json, + '$.type', 'user_message', '$.inputIntent', 'turn_start', + '$.createdBy', 'agent', '$.creationSource', 'provider', + '$.messageId', 'message:bounded-sql-history:agent-prompt', + '$.text', 'Continue the child task', '$.attachments', json('[]')) + WHERE turn_item_id = ${agentPromptId} + `; + const agentWindow = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: THREAD_HISTORY_PAGE_POLICY.maxUserTurns, + }); + assert.lengthOf( + agentWindow.projection.visibleTurnItems.filter( + (row) => row.item.type !== "run_interrupt_request", + ), + sqlPageLimit, + ); + assert.isTrue( + agentWindow.projection.visibleTurnItems.some((row) => row.sourceItemId === agentPromptId), + ); }), ); @@ -2276,8 +2306,33 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { }, }); yield* assertSummary("Plan limit reached.", "usage_limit"); + // A restart continuation ran ahead of the held queue and ended before the + // resumed failed run: the failure is still the latest executed run. + const aheadRunId = RunId.make("run:limit-shell:ran-ahead"); + yield* store.apply({ + id: EventId.make("event:limit-shell:ran-ahead"), + type: "run.created", + threadId, + runId: aheadRunId, + nodeId: NodeId.make("node:limit-shell:ran-ahead"), + driver, + providerInstanceId, + occurredAt: now, + payload: { + ...original, + id: aheadRunId, + ordinal: original.ordinal + 3, + rootNodeId: NodeId.make("node:limit-shell:ran-ahead"), + userMessageId: MessageId.make("message:limit-shell:ran-ahead"), + status: "completed", + startedAt: DateTime.subtract(now, { minutes: 10 }), + completedAt: DateTime.subtract(now, { minutes: 5 }), + }, + }); + yield* assertSummary("Plan limit reached.", "usage_limit"); const sql = yield* SqlClient.SqlClient; // The rest of this case treats the failed run as the latest run. + yield* sql`DELETE FROM orchestration_v2_projection_runs WHERE run_id = ${aheadRunId}`; yield* sql`DELETE FROM orchestration_v2_projection_runs WHERE run_id = ${queuedRunId}`; yield* assertSummary("Plan limit reached.", "usage_limit"); yield* sql`DELETE FROM orchestration_v2_projection_runs WHERE run_id = ${cancelledRunId}`; @@ -4372,6 +4427,36 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { released && projectThreadAwarenessV2({ environmentId, project, thread: released }); assert.equal(state?.phase, "completed"); assert.equal(state?.updatedAt, DateTime.formatIso(releasedAt)); + + // A later provider must not hide this owner's roster from restart recovery. + yield* store.apply({ + id: EventId.make("event:held-completion:monitor-restarted"), + type: "provider-thread.updated", + threadId, + occurredAt: releasedAt, + payload: { ...providerThread, updatedAt: releasedAt }, + }); + const newerRunId = RunId.make("run:held-completion:new-provider"); + yield* store.apply({ + id: EventId.make("event:held-completion:new-provider"), + type: "run.created", + threadId, + runId: newerRunId, + occurredAt: releasedAt, + payload: { + ...(yield* store.getThreadProjection(threadId)).runs[0]!, + id: newerRunId, + ordinal: 2, + providerThreadId: ProviderThreadId.make("provider-thread:held-completion:new-provider"), + requestedAt: releasedAt, + startedAt: releasedAt, + completedAt: releasedAt, + }, + }); + assert.deepEqual( + (yield* store.getRuntimeRecoveryProjection(threadId)).runs.map((run) => run.id), + [runId, newerRunId], + ); }), ); }); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index ac0624b05806..a1b2e0f061f1 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -159,7 +159,13 @@ export type ProjectionThreadPullRequests = Pick< "id" | "projectId" | "settledOverride" | "settledAt" | "pullRequests" >; -/** Thread activity needed by settlement, without transcript or fork history. */ +/** + * Thread activity needed by settlement, without transcript or fork history. + * `latestUserAuthoredMessageAt` is the last message the user wrote. Agent, + * provider, and server notifications also use the user role, so + * `latestUserMessageAt` moves when background work or a PR watch wakes the + * agent. + */ export type ProjectionSettlementCandidate = Pick< OrchestrationV2ThreadShell, | "id" @@ -187,7 +193,7 @@ export type ProjectionSettlementCandidate = Pick< | "activityRunStatus" | "pendingRuntimeRequest" | "pendingBackgroundTasks" ->; +> & { readonly latestUserAuthoredMessageAt: DateTime.Utc | null }; const ProjectionCheckpointContext = Schema.Struct({ runs: Schema.Array( @@ -516,8 +522,10 @@ function needsRecovery( } case "runtime": return ( - projection.runs.some((run) => - ["queued", "preparing", "starting", "running", "waiting"].includes(run.status), + projection.runs.some( + (run) => + ["preparing", "starting", "running", "waiting"].includes(run.status) || + (run.status === "queued" && run.queueHeld !== true), ) || projection.runtimeRequests.some((request) => request.status === "pending") || projection.providerSessions.some( @@ -930,7 +938,7 @@ type SettlementThreadRow = Pick< | "latest_run_started_at" | "latest_run_completed_at" | "latest_user_message_at" ->; +> & { readonly latest_user_authored_message_at: string | null }; type ShellRunItemCountRow = { readonly thread_id: string; @@ -2668,7 +2676,7 @@ export const layer: Layer.Layer = WHEN (SELECT COUNT(*) FROM turn_anchors) >= ${THREAD_HISTORY_MAX_RAW_TURNS + 2} THEN (SELECT MIN(ordinal) FROM turn_anchors) ELSE 0 - END AS ordinal, (SELECT COUNT(*) FROM turn_anchors) AS anchors + END AS ordinal FROM user_anchors ), selected AS ( SELECT payload_json, ordinal, turn_item_id, run_id, type @@ -2677,7 +2685,7 @@ export const layer: Layer.Layer = ORDER BY ordinal DESC, turn_item_id DESC LIMIT CASE WHEN ${window.rowLimit} = 0 THEN 0 - WHEN (SELECT anchors FROM boundary) > 0 THEN -1 + WHEN (SELECT COUNT(*) FROM user_anchors) > 0 THEN -1 ELSE ${window.rowLimit} END ), retained AS ( @@ -3157,7 +3165,7 @@ export const layer: Layer.Layer = localWindow !== undefined && localWindow.rowLimit > 0 && projection.turnItems.length >= localWindow.rowLimit && - !projection.turnItems.some(isThreadHistoryTurnStart) + !projection.turnItems.some(isThreadHistoryUserTurn) ) { return withLocalVisibleTurnItems(projection); } @@ -3301,7 +3309,10 @@ export const layer: Layer.Layer = latest.status = 'cancelled' AND json_extract(latest.payload_json, '$.startedAt') IS NULL ) - ORDER BY latest.ordinal DESC, latest.run_id DESC LIMIT 1 + -- latestExecutedRun: the run that ended last (runRanAfter). + ORDER BY latest.completed_at IS NULL DESC, latest.completed_at DESC, + latest.ordinal DESC, latest.run_id DESC + LIMIT 1 ) AND r.status = 'failed' INNER JOIN orchestration_v2_projection_turn_items item ON item.turn_item_id = ( SELECT error.turn_item_id FROM orchestration_v2_projection_turn_items error @@ -3427,7 +3438,15 @@ export const layer: Layer.Layer = ELSE 0 END ) SELECT thread_id FROM orchestration_v2_projection_runs - WHERE status IN ('queued', 'preparing', 'starting', 'running', 'waiting') + WHERE status IN ('preparing', 'starting', 'running', 'waiting') + UNION + -- A held queue already went through recovery; rereading it on + -- every boot costs a projection read per held thread. + SELECT thread_id FROM orchestration_v2_projection_runs + WHERE status = 'queued' + AND CASE WHEN json_valid(payload_json) + THEN json_extract(payload_json, '$.queueHeld') IS NOT 1 + ELSE 1 END UNION SELECT thread_id FROM orchestration_v2_projection_runtime_requests WHERE status = 'pending' @@ -3799,6 +3818,24 @@ export const layer: Layer.Layer = WHERE latest.thread_id = ${threadId} ORDER BY latest.ordinal DESC LIMIT 1 ) + -- Retain the latest run for each provider thread with lost + -- background work, including owners used before a handoff. + OR run.run_id IN ( + SELECT ( + SELECT ended.run_id FROM orchestration_v2_projection_runs AS ended + WHERE ended.thread_id = ${threadId} + AND ended.provider_thread_id = roster.provider_thread_id + AND ended.status NOT IN ('queued', 'rolled_back') + ORDER BY ended.completed_at IS NULL DESC, ended.completed_at DESC, + ended.ordinal DESC + LIMIT 1 + ) + FROM orchestration_v2_projection_provider_threads AS roster + WHERE roster.thread_id = ${threadId} + AND CASE WHEN json_valid(roster.payload_json) + THEN json_array_length(roster.payload_json, '$.pendingBackgroundTasks') > 0 + ELSE 0 END + ) OR run.run_id IN ( SELECT item.run_id FROM orchestration_v2_projection_turn_items AS item WHERE item.thread_id = ${threadId} @@ -4893,7 +4930,10 @@ export const layer: Layer.Layer = candidate.status = 'cancelled' AND json_extract(candidate.payload_json, '$.startedAt') IS NULL ) - ORDER BY candidate.ordinal DESC, candidate.run_id DESC LIMIT 1 + -- latestExecutedRun: the run that ended last (runRanAfter). + ORDER BY candidate.completed_at IS NULL DESC, candidate.completed_at DESC, + candidate.ordinal DESC, candidate.run_id DESC + LIMIT 1 ) AND blocked.status = 'failed' WHERE t.deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND t.thread_id = ${threadId}`}${ location === "active" @@ -5051,7 +5091,15 @@ export const layer: Layer.Layer = WHERE message.thread_id = t.thread_id AND message.role = 'user' ORDER BY message.updated_at DESC, message.message_id DESC LIMIT 1 - ) AS latest_user_message_at + ) AS latest_user_message_at, + ( + SELECT message.updated_at + FROM orchestration_v2_projection_messages message + WHERE message.thread_id = t.thread_id AND message.role = 'user' + AND json_extract(message.payload_json, '$.createdBy') = 'user' + ORDER BY message.updated_at DESC, message.message_id DESC + LIMIT 1 + ) AS latest_user_authored_message_at FROM orchestration_v2_projection_threads t LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = ( SELECT latest.run_id FROM orchestration_v2_projection_runs latest @@ -5113,6 +5161,10 @@ export const layer: Layer.Layer = row.latest_user_message_at === null ? null : DateTime.makeUnsafe(row.latest_user_message_at), + latestUserAuthoredMessageAt: + row.latest_user_authored_message_at === null + ? null + : DateTime.makeUnsafe(row.latest_user_authored_message_at), activityRunStatus: null, activityRunStartedAt: null, pendingRuntimeRequest: null, @@ -5578,7 +5630,16 @@ export const layerMemory: Layer.Layer = Layer.effect( !runs.some(isActivityRunForShell) && !runtimeRequests.some((request) => request.status === "pending"), ) - .map(threadShellFromProjection) + .map((projection) => ({ + ...threadShellFromProjection(projection), + latestUserAuthoredMessageAt: + projection.messages + .filter((message) => message.role === "user" && message.createdBy === "user") + .map((message) => message.updatedAt) + .toSorted( + (left, right) => DateTime.toEpochMillis(right) - DateTime.toEpochMillis(left), + )[0] ?? null, + })) .toSorted( (left, right) => DateTime.toEpochMillis(left.updatedAt) - DateTime.toEpochMillis(right.updatedAt) || @@ -6058,7 +6119,7 @@ export const layerMemory: Layer.Layer = Layer.effect( ); const anchorLimit = (options.userTurnLimit ?? 0) + 2; const start = - turnAnchors.length > 0 + anchors.length > 0 ? anchors.length < anchorLimit ? rawStart : anchors.at(-anchorLimit)! diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts new file mode 100644 index 000000000000..b000351508aa --- /dev/null +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts @@ -0,0 +1,510 @@ +import { assert, it } from "@effect/vitest"; +import { + CheckpointScopeId, + CommandId, + EventId, + MessageId, + NodeId, + type OrchestrationV2AppThread, + type OrchestrationV2DomainEvent, + type OrchestrationV2Run, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, + RunAttemptId, + RunId, + ThreadId, + TurnItemId, +} from "@t3tools/contracts"; +import * as Console from "effect/Console"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as EffectOutbox from "./EffectOutbox.ts"; +import * as EventSink from "./EventSink.ts"; +import * as EventStore from "./EventStore.ts"; +import * as IdAllocator from "./IdAllocator.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProviderRuntimeRecovery from "./ProviderRuntimeRecoveryService.ts"; + +// Startup recovery cost for restart continuation. The default case is small +// enough for CI and asserts correctness; T3_BENCH_RECOVERY=1 adds the matrix. +// `it.live` keeps a real clock: each reconcile gets a fresh command id, so the +// second recover cannot hide behind command receipt dedup. + +const stores = Layer.mergeAll( + EventStore.layer, + ProjectionStore.layer, + EffectOutbox.layer, + IdAllocator.layer, +).pipe(Layer.provideMerge(SqlitePersistenceMemory)); +const TestLayer = ProviderRuntimeRecovery.layer.pipe( + Layer.provideMerge(EventSink.layer.pipe(Layer.provideMerge(stores))), + Layer.provideMerge(ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true })), +); + +const providerInstanceId = ProviderInstanceId.make("codex"); +const driver = ProviderDriverKind.make("codex"); +const modelSelection = { instanceId: providerInstanceId, model: "gpt-5.4" }; +const projectId = ProjectId.make("project:recovery-bench"); +const ITEMS_PER_SETTLED_THREAD = 10; +const OPEN_ITEMS_PER_ACTIVE_THREAD = 3; +const WAITING_THREADS = 3; +const SEED_BATCH = 500; + +type UnstampedEvent = OrchestrationV2DomainEvent extends infer Event + ? Event extends OrchestrationV2DomainEvent + ? Omit + : never + : never; + +interface Scenario { + readonly settled: number; + readonly active: number; +} + +interface Seeded { + readonly unheldQueuedThreads: number; + readonly waitingThreads: number; +} + +const seedScenario = Effect.fn(function* (scenario: Scenario) { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const sql = yield* SqlClient.SqlClient; + const now = yield* DateTime.now; + let eventIndex = 0; + const apply = (event: UnstampedEvent) => + projections.apply({ + ...event, + id: EventId.make(`event:bench:${eventIndex++}`), + occurredAt: now, + } as OrchestrationV2DomainEvent); + + const thread = (id: ThreadId, overrides: Partial = {}) => + apply({ + type: "thread.created", + threadId: id, + payload: { + createdBy: "user", + creationSource: "web", + id, + projectId, + title: id, + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: id }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + ...overrides, + }, + }); + + const run = ( + threadId: ThreadId, + ordinal: number, + status: OrchestrationV2Run["status"], + overrides: Partial = {}, + ) => { + const runId = RunId.make(`run:${threadId}:${ordinal}`); + return apply({ + type: "run.created", + threadId, + runId, + payload: { + id: runId, + threadId, + ordinal, + providerInstanceId, + modelSelection, + providerThreadId: null, + userMessageId: MessageId.make(`message:${runId}`), + rootNodeId: null, + activeAttemptId: null, + status, + requestedAt: now, + startedAt: status === "queued" ? null : now, + completedAt: status === "completed" ? now : null, + checkpointId: null, + contextHandoffId: null, + ...overrides, + }, + }).pipe(Effect.as(runId)); + }; + + const commandItem = (input: { + readonly threadId: ThreadId; + readonly runId: RunId; + readonly ordinal: number; + readonly status: "running" | "completed"; + readonly nodeId?: NodeId; + readonly providerThreadId?: ProviderThreadId; + readonly providerTurnId?: ProviderTurnId; + }) => + apply({ + type: "turn-item.updated", + threadId: input.threadId, + runId: input.runId, + payload: { + id: TurnItemId.make(`item:${input.runId}:${input.ordinal}`), + threadId: input.threadId, + runId: input.runId, + nodeId: input.nodeId ?? null, + providerThreadId: input.providerThreadId ?? null, + providerTurnId: input.providerTurnId ?? null, + nativeItemRef: null, + parentItemId: null, + ordinal: input.ordinal, + type: "command_execution", + status: input.status, + title: `Command ${input.ordinal}`, + input: `echo ${input.ordinal}`, + output: input.status === "completed" ? `${input.ordinal}\n` : "", + ...(input.status === "completed" ? { exitCode: 0 } : {}), + startedAt: now, + completedAt: input.status === "completed" ? now : null, + updatedAt: now, + }, + }); + + const settledThread = Effect.fn(function* (index: number) { + const threadId = ThreadId.make(`thread:bench:settled:${index}`); + yield* thread(threadId); + const runId = yield* run(threadId, 1, "completed"); + for (let ordinal = 1; ordinal <= ITEMS_PER_SETTLED_THREAD; ordinal += 1) { + yield* commandItem({ threadId, runId, ordinal, status: "completed" }); + } + // ~5% of threads carry a queued follow-up; half are already held, and an + // earlier recovery already handled those, so only unheld ones are candidates. + if (index % 20 === 0) { + const queueHeld = index % 40 === 0; + yield* run(threadId, 2, "queued", { queueHeld }); + return !queueHeld; + } + return false; + }); + + const activeThread = Effect.fn(function* (index: number) { + const threadId = ThreadId.make(`thread:bench:active:${index}`); + const providerSessionId = ProviderSessionId.make(`session:bench:${index}`); + const providerThreadId = ProviderThreadId.make(`provider-thread:bench:${index}`); + const runId = RunId.make(`run:${threadId}:1`); + const attemptId = RunAttemptId.make(`attempt:bench:${index}`); + const nodeId = NodeId.make(`node:bench:${index}`); + const providerTurnId = ProviderTurnId.make(`provider-turn:bench:${index}`); + yield* thread(threadId, { activeProviderThreadId: providerThreadId }); + yield* apply({ + type: "provider-session.attached", + threadId, + driver, + providerInstanceId, + payload: { + id: providerSessionId, + driver, + providerInstanceId, + status: "ready", + cwd: "/workspace", + model: modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + }); + yield* apply({ + type: "provider-thread.updated", + threadId, + driver, + providerInstanceId, + payload: { + id: providerThreadId, + appThreadId: threadId, + ownerNodeId: null, + driver, + providerInstanceId, + providerSessionId, + nativeThreadRef: { driver, nativeId: `native:bench:${index}`, strength: "strong" }, + nativeConversationHeadRef: null, + status: "active", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + pendingBackgroundTasks: [], + }, + }); + yield* run(threadId, 1, "running", { + providerThreadId, + rootNodeId: nodeId, + activeAttemptId: attemptId, + }); + yield* apply({ + type: "run-attempt.created", + threadId, + runId, + payload: { + id: attemptId, + runId, + attemptOrdinal: 1, + rootNodeId: nodeId, + providerInstanceId, + providerThreadId, + providerTurnId, + reason: "initial", + status: "running", + startedAt: now, + completedAt: null, + }, + }); + yield* apply({ + type: "node.updated", + threadId, + runId, + nodeId, + providerInstanceId, + payload: { + id: nodeId, + threadId, + runId, + parentNodeId: null, + rootNodeId: nodeId, + kind: "root_turn", + status: "running", + countsForRun: true, + providerThreadId, + providerTurnId, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }); + yield* apply({ + type: "provider-turn.updated", + threadId, + runId, + nodeId, + providerInstanceId, + payload: { + id: providerTurnId, + providerThreadId, + nodeId, + runAttemptId: attemptId, + nativeTurnRef: { driver, nativeId: `native-turn:bench:${index}`, strength: "strong" }, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + for (let ordinal = 1; ordinal <= OPEN_ITEMS_PER_ACTIVE_THREAD; ordinal += 1) { + yield* commandItem({ + threadId, + runId, + ordinal, + status: "running", + nodeId, + providerThreadId, + providerTurnId, + }); + } + }); + + const waitingThread = Effect.fn(function* (index: number) { + const threadId = ThreadId.make(`thread:bench:waiting:${index}`); + yield* thread(threadId); + yield* run(threadId, 1, "completed"); + const runId = yield* run(threadId, 2, "waiting"); + yield* outbox.enqueue([ + { + id: `effect:bench:checkpoint:${runId}`, + commandId: CommandId.make(`command:effect:checkpoint.capture:${runId}`), + threadId, + request: { + type: "checkpoint.capture", + runId, + scopeId: CheckpointScopeId.make(`scope:bench:${index}`), + }, + }, + ]); + }); + + let unheldQueuedThreads = 0; + for (let start = 0; start < scenario.settled; start += SEED_BATCH) { + const end = Math.min(scenario.settled, start + SEED_BATCH); + yield* sql.withTransaction( + Effect.gen(function* () { + for (let index = start; index < end; index += 1) { + if (yield* settledThread(index)) unheldQueuedThreads += 1; + } + }), + ); + } + yield* sql.withTransaction( + Effect.gen(function* () { + for (let index = 0; index < scenario.active; index += 1) yield* activeThread(index); + for (let index = 0; index < WAITING_THREADS; index += 1) yield* waitingThread(index); + }), + ); + return { unheldQueuedThreads, waitingThreads: WAITING_THREADS } satisfies Seeded; +}); + +const timed = (effect: Effect.Effect) => + Effect.gen(function* () { + const start = performance.now(); + const value = yield* effect; + return [value, performance.now() - start] as const; + }); + +const continuationEffectCount = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM orchestration_v2_effect_outbox + WHERE effect_id LIKE 'effect:restart-continuation:%' AND status = 'pending' + `; + return Number(rows[0]?.count ?? 0); +}); + +interface Measurement { + readonly settled: number; + readonly active: number; + readonly seedMs: number; + readonly candidates: number; + readonly selectMs: number; + readonly prepareMs: number; + readonly shutdownMs: number; + readonly startupAfterShutdownMs: number; + readonly crashRecoverMs: number; + readonly secondRecoverMs: number; +} + +/** A graceful restart: prepare intent, reconcile on shutdown, recover on boot. */ +const measureGraceful = Effect.fn(function* (scenario: Scenario) { + const [seeded, seedMs] = yield* timed(seedScenario(scenario)); + const projections = yield* ProjectionStore.ProjectionStoreV2; + const recovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; + const [candidates, selectMs] = yield* timed(projections.getRecoveryThreadIds("runtime")); + assert.equal( + candidates.length, + scenario.active + seeded.unheldQueuedThreads + seeded.waitingThreads, + "runtime recovery candidates", + ); + const [, prepareMs] = yield* timed(recovery.prepareForShutdown); + assert.equal(yield* continuationEffectCount, scenario.active, "prepared continuations"); + const [, shutdownMs] = yield* timed(recovery.reconcile("shutdown")); + const [, startupAfterShutdownMs] = yield* timed(recovery.recover); + assert.equal(yield* continuationEffectCount, scenario.active, "continuations after boot"); + return { + seedMs, + candidates: candidates.length, + selectMs, + prepareMs, + shutdownMs, + startupAfterShutdownMs, + }; +}); + +/** A crash: no shutdown hook ran, so boot recovery records the continuations. */ +const measureCrash = Effect.fn(function* (scenario: Scenario) { + yield* seedScenario(scenario); + const recovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; + const [summary, crashRecoverMs] = yield* timed(recovery.recover); + assert.equal(summary.terminalizedRuns, scenario.active, "terminalized active runs"); + assert.equal(summary.stoppedSessions, scenario.active, "stopped active sessions"); + assert.equal(yield* continuationEffectCount, scenario.active, "recorded continuations"); + const [, secondRecoverMs] = yield* timed(recovery.recover); + assert.equal(yield* continuationEffectCount, scenario.active, "second recover is idempotent"); + return { crashRecoverMs, secondRecoverMs }; +}); + +const measure = Effect.fn(function* (scenario: Scenario) { + const graceful = yield* measureGraceful(scenario).pipe(Effect.provide(Layer.fresh(TestLayer))); + const crash = yield* measureCrash(scenario).pipe(Effect.provide(Layer.fresh(TestLayer))); + return { ...scenario, ...graceful, ...crash } satisfies Measurement; +}); + +const formatTable = (rows: ReadonlyArray) => { + const ms = (value: number) => value.toFixed(1); + const perActive = (value: number, active: number) => (value / Math.max(active, 1)).toFixed(2); + const header = [ + "N", + "A", + "seed", + "cand", + "select", + "prepare", + "prep/A", + "shutdown", + "boot-after", + "crash-recover", + "recover/A", + "recover-2", + ]; + const lines = rows.map((row) => [ + String(row.settled), + String(row.active), + ms(row.seedMs), + String(row.candidates), + ms(row.selectMs), + ms(row.prepareMs), + perActive(row.prepareMs, row.active), + ms(row.shutdownMs), + ms(row.startupAfterShutdownMs), + ms(row.crashRecoverMs), + perActive(row.crashRecoverMs, row.active), + ms(row.secondRecoverMs), + ]); + const widths = header.map((cell, column) => + Math.max(cell.length, ...lines.map((line) => line[column]!.length)), + ); + return [header, ...lines] + .map((line) => line.map((cell, column) => cell.padStart(widths[column]!)).join(" ")) + .join("\n"); +}; + +it.live( + "restart recovery records one continuation per active thread and is idempotent", + () => + Effect.gen(function* () { + const row = yield* measure({ settled: 500, active: 20 }); + yield* Console.log(`provider runtime recovery (ms)\n${formatTable([row])}`); + }), + 60_000, +); + +it.live.skipIf(process.env.T3_BENCH_RECOVERY !== "1")( + "restart recovery scales with active threads, not settled history", + () => + Effect.gen(function* () { + const rows: Array = []; + for (const settled of [5_000, 50_000]) { + for (const active of [10, 100, 1_000]) { + rows.push(yield* measure({ settled, active })); + yield* Console.log(`provider runtime recovery (ms)\n${formatTable(rows)}`); + } + } + }), + 3_600_000, +); diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts index 8df85818a761..b36239de290d 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts @@ -1272,3 +1272,151 @@ it.effect( }).pipe(Effect.provide(layer)); }, ); + +it.effect("leaves delegated tasks to their own child threads after process loss", () => { + const threadId = ThreadId.make("thread_recovery_delegation"); + const settledRunId = RunId.make("run_recovery_delegation_settled"); + const runningRunId = RunId.make("run_recovery_delegation_running"); + const providerThreadId = ProviderThreadId.make("provider_thread_recovery_delegation"); + const claudeInstanceId = ProviderInstanceId.make("claude"); + const driver = ProviderDriverKind.make("claude"); + const settledTaskId = NodeId.make("node_recovery_delegation_settled"); + const runningTaskId = NodeId.make("node_recovery_delegation_running"); + const nativeSubagentId = NodeId.make("node_recovery_native_subagent"); + const delegatedTask = (id: NodeId, runId: RunId) => ({ + id, + runId, + origin: "app_owned", + childThreadId: ThreadId.make(`thread:delegated-task:${id}`), + driver, + providerInstanceId: claudeInstanceId, + status: "running", + }); + const subagentItem = (subagentId: NodeId, runId: RunId, origin: string) => ({ + id: TurnItemId.make(`turn_item:${subagentId}`), + runId, + nodeId: subagentId, + providerThreadId, + type: "subagent", + status: "running", + subagentId, + origin, + childThreadId: + origin === "app_owned" ? ThreadId.make(`thread:delegated-task:${subagentId}`) : null, + providerInstanceId: claudeInstanceId, + title: `subagent ${subagentId}`, + }); + let committedInput: Parameters[0] | null = + null; + const projection = { + thread: { id: threadId, providerInstanceId: claudeInstanceId }, + runtimeRequests: [], + providerSessions: [], + providerThreads: [ + { + id: providerThreadId, + driver, + providerInstanceId: claudeInstanceId, + ownerNodeId: null, + status: "idle", + pendingBackgroundTasks: [], + }, + ], + providerTurns: [], + runs: [ + { + id: settledRunId, + ordinal: 1, + status: "completed", + providerThreadId, + providerInstanceId: claudeInstanceId, + }, + { + id: runningRunId, + ordinal: 2, + status: "running", + providerThreadId, + providerInstanceId: claudeInstanceId, + }, + ], + attempts: [], + nodes: [ + { id: settledTaskId, runId: settledRunId, status: "running", kind: "subagent" }, + { id: runningTaskId, runId: runningRunId, status: "running", kind: "subagent" }, + { id: nativeSubagentId, runId: settledRunId, status: "running", kind: "subagent" }, + ], + subagents: [ + delegatedTask(settledTaskId, settledRunId), + delegatedTask(runningTaskId, runningRunId), + { + ...delegatedTask(nativeSubagentId, settledRunId), + origin: "provider_native", + childThreadId: null, + }, + ], + messages: [], + turnItems: [ + subagentItem(settledTaskId, settledRunId, "app_owned"), + subagentItem(runningTaskId, runningRunId, "app_owned"), + subagentItem(nativeSubagentId, settledRunId, "provider_native"), + ], + } as unknown as OrchestrationV2ThreadProjection; + const layer = ProviderRuntimeRecovery.layer.pipe( + Layer.provide(ServerSettings.layerTest()), + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getRecoveryThreadIds: () => Effect.succeed([threadId]), + getRuntimeRecoveryProjection: () => Effect.succeed(projection), + }), + Layer.mock(EventSink.EventSinkV2)({ + commitCommand: (input) => { + committedInput = input; + return Effect.succeed({ committed: true, cancelledEffectCount: 0 } as never); + }, + }), + IdAllocator.layer, + Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ + runRecoveryOnce: Effect.succeed(false), + }), + Layer.mock(EffectOutbox.EffectOutboxV2)({ + listByCommandId: () => Effect.succeed([]), + reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), + }), + ), + ), + ); + + return Effect.gen(function* () { + yield* (yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService).reconcile("startup"); + const events = committedInput?.events ?? []; + const touched = events.flatMap((event) => + event.type === "turn-item.updated" + ? event.payload.type === "subagent" + ? [event.payload.subagentId] + : [] + : event.type === "subagent.updated" || event.type === "node.updated" + ? [event.payload.id] + : [], + ); + // The cut run itself is cancelled; only the provider-native subagent dies with it. + assert.isTrue( + events.some( + (event) => + event.type === "run.updated" && + event.payload.id === runningRunId && + event.payload.status === "cancelled", + ), + ); + assert.notInclude(touched, settledTaskId); + assert.notInclude(touched, runningTaskId); + assert.include(touched, nativeSubagentId); + // The note lists only provider-native work: delegated tasks report back on their own. + const noted = events.flatMap((event) => + event.type === "run.background-work-cancelled" + ? event.payload.restartCancelledBackgroundWork.map((work) => work.label) + : [], + ); + assert.deepEqual(noted, [`subagent ${nativeSubagentId}`]); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index 67ae22f1883b..98287944dee1 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -1,12 +1,15 @@ +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { CommandId, type OrchestrationV2DomainEvent, type ProviderThreadId, type OrchestrationV2RestartCancelledBackgroundWork, + type OrchestrationV2Subagent, type OrchestrationV2ThreadProjection, ThreadId, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -93,6 +96,23 @@ function isNonterminalNodeStatus(status: string): boolean { return status === "pending" || status === "running" || status === "waiting"; } +/** + * A delegate_task child. Its own thread is reconciled and continued on its own + * and reports back through the app, so it is not provider background work. + */ +function isAppOwnedDelegation(task: { + readonly origin: OrchestrationV2Subagent["origin"]; + readonly childThreadId: ThreadId | null; +}): boolean { + return task.origin === "app_owned" && task.childThreadId !== null; +} + +function isAppOwnedDelegationItem( + item: OrchestrationV2ThreadProjection["turnItems"][number], +): boolean { + return item.type === "subagent" && isAppOwnedDelegation(item); +} + function providerThreadHasPendingBackgroundTasks( providerThread: OrchestrationV2ThreadProjection["providerThreads"][number], ): boolean { @@ -138,7 +158,11 @@ function providerThreadsWithOpenBackgroundWork( ): ReadonlySet { const ids = new Set(); for (const item of projection.turnItems ?? []) { - if (!isBackgroundCapableTurnItemType(item.type) || !isNonterminalTurnItemStatus(item.status)) + if ( + !isBackgroundCapableTurnItemType(item.type) || + !isNonterminalTurnItemStatus(item.status) || + isAppOwnedDelegationItem(item) + ) continue; const providerThreadId = item.providerThreadId ?? @@ -166,7 +190,7 @@ function latestStartedRun( run.providerThreadId === providerThreadId && run.status !== "queued" && run.status !== "rolled_back" && - (latest === undefined || run.ordinal > latest.ordinal) + (latest === undefined || runRanAfter(run, latest)) ? run : latest, undefined, @@ -222,6 +246,13 @@ export const make = Effect.gen(function* () { const requests = projection.runtimeRequests.filter( (request) => request.status === "pending" && request.responseCapability.type !== "message", ); + // Delegated task rows, items and nodes stay open: the child settles them. + const delegatedTaskNodeIds = new Set([ + ...(projection.subagents ?? []).filter(isAppOwnedDelegation).map((subagent) => subagent.id), + ...(projection.turnItems ?? []).flatMap((item) => + item.type === "subagent" && isAppOwnedDelegation(item) ? [item.subagentId] : [], + ), + ]); const detail = `Cancelled because the server ${trigger === "startup" ? "restarted" : "shut down"} before the provider work completed.`; const commandId = CommandId.make( `command:runtime-reconcile:${trigger}:${projection.thread.id}:${DateTime.formatIso(now)}`, @@ -333,6 +364,7 @@ export const make = Effect.gen(function* () { (candidate) => candidate.runId === run.id && !messageRequestNodeIds.has(candidate.id) && + !delegatedTaskNodeIds.has(candidate.id) && (candidate.status === "pending" || candidate.status === "running" || candidate.status === "waiting"), @@ -351,6 +383,7 @@ export const make = Effect.gen(function* () { for (const subagent of projection.subagents.filter( (candidate) => candidate.runId === run.id && + !isAppOwnedDelegation(candidate) && (candidate.status === "pending" || candidate.status === "running" || candidate.status === "waiting"), @@ -404,6 +437,7 @@ export const make = Effect.gen(function* () { (candidate) => candidate.runId === run.id && (candidate.nodeId === null || !messageRequestNodeIds.has(candidate.nodeId)) && + !isAppOwnedDelegationItem(candidate) && (candidate.status === "pending" || candidate.status === "running" || candidate.status === "waiting"), @@ -436,7 +470,7 @@ export const make = Effect.gen(function* () { if (!isBackgroundCapableTurnItemType(item.type)) { continue; } - if (!isNonterminalTurnItemStatus(item.status)) { + if (!isNonterminalTurnItemStatus(item.status) || isAppOwnedDelegationItem(item)) { continue; } const providerInstanceId = resolveStaleBackgroundItemProviderInstanceId(item, projection); @@ -763,32 +797,41 @@ export const make = Effect.gen(function* () { if (!enabled) return; const threadIds = yield* projections.getRecoveryThreadIds("runtime"); for (const threadId of threadIds) { - const projection = yield* projections.getRuntimeRecoveryProjection(threadId); - if ( - !resolveProjectSettings(enabled, projection.thread.projectId).settings - .continueThreadsAfterServerUpdate - ) - continue; - // Shutdown reconciliation cancels the background work below, so a - // settled thread's continuation must be captured while it is still open. - const run = restartContinuationRun( - projection, - providerThreadsWithOpenBackgroundWork(projection), + yield* Effect.gen(function* () { + const projection = yield* projections.getRuntimeRecoveryProjection(threadId); + if ( + !resolveProjectSettings(enabled, projection.thread.projectId).settings + .continueThreadsAfterServerUpdate + ) + return; + // Shutdown reconciliation cancels the background work below, so a + // settled thread's continuation must be captured while it is still open. + const run = restartContinuationRun( + projection, + providerThreadsWithOpenBackgroundWork(projection), + ); + if (!run) return; + const commandId = CommandId.make(`command:restart-prepare:${run.id}`); + yield* eventSink.writeWithEffects({ + commandId, + events: [], + effects: [ + { + id: `effect:restart-continuation:${run.id}`, + commandId, + threadId, + request: { type: "provider-runtime.continue", sourceRunId: run.id }, + }, + ], + }); + }).pipe( + // One failing thread must not cost the threads after it their continuation. + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("Failed to prepare a restart continuation", { threadId, cause }), + ), ); - if (!run) continue; - const commandId = CommandId.make(`command:restart-prepare:${run.id}`); - yield* eventSink.writeWithEffects({ - commandId, - events: [], - effects: [ - { - id: `effect:restart-continuation:${run.id}`, - commandId, - threadId, - request: { type: "provider-runtime.continue", sourceRunId: run.id }, - }, - ], - }); } }).pipe( Effect.mapError((cause) => new ProviderRuntimeRecoveryError({ operation: "reconcile", cause })), diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index c1039c2a0207..20e240e79c58 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -362,6 +362,25 @@ export const layerWithOptions = ( }, ); const layerScope = yield* Effect.scope; + // Ctrl+C, or a stop that signals the whole process group, reaches the + // provider CLIs with the server. They report their own background work + // stopped before shutdown captures restart continuations, so provider + // events after the signal are dropped; restart recovery owns that state. + const shutdownSignal = { received: false }; + const onShutdownSignal = () => { + shutdownSignal.received = true; + }; + yield* Effect.acquireRelease( + Effect.sync(() => { + process.on("SIGINT", onShutdownSignal); + process.on("SIGTERM", onShutdownSignal); + }), + () => + Effect.sync(() => { + process.off("SIGINT", onShutdownSignal); + process.off("SIGTERM", onShutdownSignal); + }), + ); const sessions = yield* Ref.make(new Map()); // One retry per released entry, so a later release with the same id // cannot drop cleanup for threads only the earlier session served. @@ -1542,6 +1561,7 @@ export const layerWithOptions = ( let stoppedByProvider = false; return entry.runtime.events.pipe( Stream.runForEach((event) => { + if (shutdownSignal.received) return Effect.void; if ( event.type === "provider_session.updated" && event.providerSession.status === "stopped" @@ -1600,6 +1620,8 @@ export const layerWithOptions = ( Effect.exit, Effect.flatMap((exit) => Effect.gen(function* () { + // A provider that exits on the shutdown signal is released by shutdown. + if (shutdownSignal.received) return; const current = (yield* Ref.get(sessions)).get( sessionKey(entry.runtime.providerSessionId), ); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index ce2088d911d4..a3e3b122ca45 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -953,6 +953,7 @@ export const layer: Layer.Layer< run, projection.runs, projection.providerTurns, + projection.attempts, ); const restartCancelledWork = pendingRestartCancelledBackgroundWork({ runs: projection.runs, @@ -967,9 +968,7 @@ export const layer: Layer.Layer< .map((candidate) => candidate.id), ), run, - runAttemptIds: projection.attempts - .filter((candidate) => candidate.runId === run.id) - .map((candidate) => candidate.id), + attempts: projection.attempts, }); const restartNote = restartCancelledWork.length === 0 diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts index f5215c1f3b6b..2f22ed583a91 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts @@ -6,12 +6,15 @@ import { RunId, type OrchestrationV2Run, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import { cancelledRosterTaskWork, cancelledTurnItemWork, + isRestartNoteContinuation, pendingRestartCancelledBackgroundWork, restartCancelledBackgroundWorkNote, + restartContinuationNote, mergeRestartCancelledBackgroundWork, } from "./RestartBackgroundNote.ts"; @@ -51,7 +54,7 @@ it("keeps the note for the provider thread that lost the work across a provider providerTurns: runs.filter((source) => source.id !== target.id).map(turnFor), compactionMessageIds: new Set(), run: target, - runAttemptIds: target.activeAttemptId === null ? [] : [target.activeAttemptId], + attempts: [{ id: target.activeAttemptId!, runId: target.id }], }); // Codex never lost the work, so it neither receives nor consumes the note. @@ -62,6 +65,30 @@ it("keeps the note for the provider thread that lost the work across a provider assert.deepEqual(pending(later, [root, onCodex, backOnClaude, later]), []); }); +it("delivers a resumed queued run's note even after a higher-ordinal run", () => { + // Run 3 ran ahead of held run 2; run 2 then resumed, lost background work in + // a second restart, and its continuation was superseded by a new message. + const ranFirst = run(3, claudeThread, { + completedAt: DateTime.makeUnsafe("2026-10-03T10:00:00.000Z"), + }); + const resumed = run(2, claudeThread, { + completedAt: DateTime.makeUnsafe("2026-10-03T10:05:00.000Z"), + restartCancelledBackgroundWork: lost, + }); + const next = run(4, claudeThread); + const runs = [resumed, ranFirst, next]; + assert.deepEqual( + pendingRestartCancelledBackgroundWork({ + runs, + providerTurns: [resumed, ranFirst].map(turnFor), + compactionMessageIds: new Set(), + run: next, + attempts: [{ id: next.activeAttemptId!, runId: next.id }], + }), + lost, + ); +}); + it("bounds the note so it cannot crowd out the turn's context", () => { const work = Array.from({ length: 25 }, (_, index) => ({ kind: "shell" as const, @@ -123,7 +150,7 @@ it("does not repeat the note when a steer restarts the run on a new attempt", () ], compactionMessageIds: new Set(), run: steered, - runAttemptIds, + attempts: runAttemptIds.map((id) => ({ id, runId: steered.id })), }); // The first attempt reached the provider with the note; its replacement must not repeat it. @@ -131,3 +158,80 @@ it("does not repeat the note when a steer restarts the run on a new attempt", () // A first attempt that never reached the provider did not deliver it. assert.deepEqual(pending([firstAttempt, RunAttemptId.make("attempt:2b")], false), lost); }); + +it("counts a prompted mid-turn or chained continuation as delivering the note", () => { + const cut = run(1, claudeThread, { status: "cancelled", restartCancelledBackgroundWork: lost }); + const cutTurn = { ...turnFor(cut), status: "cancelled" as const }; + const continuation = run(2, claudeThread, { restartContinuationOfRunId: cut.id }); + // A turn cut mid-way that lost work is prompted with the note, not resumed natively. + assert.isTrue(isRestartNoteContinuation(continuation, [cut, continuation], [cutTurn], [])); + assert.isFalse( + isRestartNoteContinuation( + continuation, + [{ ...cut, restartCancelledBackgroundWork: [] }, continuation], + [cutTurn], + [], + ), + ); + const later = run(3, claudeThread); + const pending = (delivered: boolean) => + pendingRestartCancelledBackgroundWork({ + runs: [cut, continuation, later], + providerTurns: [cutTurn, ...(delivered ? [turnFor(continuation)] : [])], + compactionMessageIds: new Set(), + run: later, + attempts: [{ id: later.activeAttemptId!, runId: later.id }], + }); + assert.deepEqual(pending(true), []); + // A continuation that never reached the provider delivered nothing. + assert.deepEqual(pending(false), lost); + + // Its own continuation carries the original note forward. + const unstarted = { ...continuation, status: "cancelled" as const }; + const chained = run(3, claudeThread, { restartContinuationOfRunId: unstarted.id }); + assert.deepEqual(restartContinuationNote(unstarted, [cut, unstarted], [cutTurn], []), { + work: lost, + settled: false, + }); + assert.isTrue(isRestartNoteContinuation(chained, [cut, unstarted, chained], [cutTurn], [])); + // Claude announces running before accepting the prompt. A second restart + // can cancel that turn without delivering anything to the provider. + for (const status of ["running", "cancelled"] as const) { + const turns = [cutTurn, { ...turnFor(unstarted), status }]; + assert.deepEqual(restartContinuationNote(unstarted, [cut, unstarted], turns, []), { + work: lost, + settled: false, + }); + assert.deepEqual( + pendingRestartCancelledBackgroundWork({ + runs: [cut, unstarted, later], + providerTurns: turns, + compactionMessageIds: new Set(), + run: later, + attempts: [{ id: later.activeAttemptId!, runId: later.id }], + }), + lost, + ); + } + + // A steer replaced the attempt after its completed turn delivered the note. + const steered = { ...unstarted, activeAttemptId: RunAttemptId.make("attempt:replacement") }; + const attempts = [{ id: unstarted.activeAttemptId!, runId: steered.id }]; + const turns = [ + cutTurn, + turnFor(unstarted), + { ...turnFor(steered), status: "cancelled" as const }, + ]; + assert.deepEqual(restartContinuationNote(steered, [cut, steered], turns, attempts).work, []); + assert.isFalse(isRestartNoteContinuation(chained, [cut, steered, chained], turns, attempts)); + assert.deepEqual( + pendingRestartCancelledBackgroundWork({ + runs: [cut, steered, later], + providerTurns: turns, + compactionMessageIds: new Set(), + run: later, + attempts, + }), + [], + ); +}); diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts index 50ea52895e77..11b2cfe37c08 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts @@ -3,10 +3,13 @@ import type { OrchestrationV2ProviderTurn, OrchestrationV2RestartCancelledBackgroundWork, OrchestrationV2Run, + OrchestrationV2RunAttempt, OrchestrationV2TurnItem, } from "@t3tools/contracts"; +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; type Work = OrchestrationV2RestartCancelledBackgroundWork; +type Attempt = Pick; const MAX_LABEL_LENGTH = 160; @@ -120,25 +123,69 @@ export function isRestartNoteSource( ); } -/** A restart continuation whose prompt is the note rather than a resume. */ +/** + * The note a restart continuation of `source` carries, and whether the turn it + * continues had settled (the note is then the whole prompt). A continuation cut + * before its provider turn completed may not have delivered its own note: + * adapters can announce a running turn before accepting the prompt. Carry the + * note forward until a completed turn proves delivery. + */ +export function restartContinuationNote( + source: OrchestrationV2Run, + runs: ReadonlyArray, + providerTurns: ReadonlyArray>, + attempts: ReadonlyArray, +): { readonly work: ReadonlyArray; readonly settled: boolean } { + const completedAttempts = new Set( + providerTurns.filter((turn) => turn.status === "completed").map((turn) => turn.runAttemptId), + ); + const completedRuns = new Set( + attempts.filter((attempt) => completedAttempts.has(attempt.id)).map((attempt) => attempt.runId), + ); + let current = source; + let work = current.restartCancelledBackgroundWork ?? []; + const visited = new Set([current.id]); + while ( + current.restartContinuationOfRunId !== undefined && + !completedRuns.has(current.id) && + !(current.activeAttemptId !== null && completedAttempts.has(current.activeAttemptId)) + ) { + const previous = runs.find((candidate) => candidate.id === current.restartContinuationOfRunId); + if (previous === undefined || visited.has(previous.id)) break; + visited.add(previous.id); + current = previous; + work = mergeRestartCancelledBackgroundWork(current.restartCancelledBackgroundWork ?? [], work); + } + return { work, settled: isRestartNoteSource(current, providerTurns) }; +} + +/** + * A restart continuation prompted with the note rather than a native resume. + * A turn cut mid-way that lost background work is prompted too, so the note + * is delivered with it; only a continuation without a note resumes natively. + */ export function isRestartNoteContinuation( run: Pick, runs: ReadonlyArray, providerTurns: ReadonlyArray>, + attempts: ReadonlyArray, ): boolean { const source = run.restartContinuationOfRunId === undefined ? undefined : runs.find((candidate) => candidate.id === run.restartContinuationOfRunId); - return source !== undefined && isRestartNoteSource(source, providerTurns); + return ( + source !== undefined && + restartContinuationNote(source, runs, providerTurns, attempts).work.length > 0 + ); } /** * Work cancelled by a restart that the run's provider thread has not been told * about yet. The note belongs to the provider thread that lost the work: turns * on another provider (after a switch) neither owe it nor deliver it. A later - * run on the same provider thread delivers it once its attempt reaches the - * provider, so the pending set is derived rather than cleared. Compactions and + * completed turn on the same provider thread proves delivery, so the pending + * set is derived rather than cleared. Compactions and * resumed turns carry no note, and a rolled-back run left native history, so * none of them counts as delivery. A note continuation's own prompt is the note. */ @@ -150,13 +197,14 @@ export function pendingRestartCancelledBackgroundWork(input: { OrchestrationV2Run, | "id" | "ordinal" + | "completedAt" | "userMessageId" | "providerThreadId" | "restartContinuationOfRunId" | "activeAttemptId" >; - /** Every attempt id of `run`; a steer replaces the attempt but not the run. */ - readonly runAttemptIds: ReadonlyArray; + /** Include earlier attempts: a steer replaces the attempt but not the run. */ + readonly attempts: ReadonlyArray; }): ReadonlyArray { const isCompaction = (run: typeof input.run) => input.compactionMessageIds.has(run.userMessageId); // The current run prepends the note unless it is a compaction or a @@ -170,7 +218,7 @@ export function pendingRestartCancelledBackgroundWork(input: { const providerThreadId = input.run.providerThreadId; const deliveredAttemptIds = new Set( input.providerTurns - .filter((turn) => turn.providerThreadId === providerThreadId) + .filter((turn) => turn.providerThreadId === providerThreadId && turn.status === "completed") .map((turn) => turn.runAttemptId), ); const sameThread = input.runs.filter((run) => run.providerThreadId === providerThreadId); @@ -179,23 +227,29 @@ export function pendingRestartCancelledBackgroundWork(input: { candidate.id !== input.run.id && candidate.activeAttemptId !== null && candidate.status !== "rolled_back" && - deliveredAttemptIds.has(candidate.activeAttemptId) && + (deliveredAttemptIds.has(candidate.activeAttemptId) || + input.attempts.some( + (attempt) => attempt.runId === candidate.id && deliveredAttemptIds.has(attempt.id), + )) && !isCompaction(candidate) && (candidate.restartContinuationOfRunId === undefined || - isRestartNoteContinuation(candidate, input.runs, input.providerTurns)), + isRestartNoteContinuation(candidate, input.runs, input.providerTurns, input.attempts)), ); // A steer restarts this run on a new attempt: an earlier attempt that already // reached the provider delivered the note, so the replacement must not repeat it. - const alreadyDelivered = input.runAttemptIds.some( - (attemptId) => attemptId !== input.run.activeAttemptId && deliveredAttemptIds.has(attemptId), + const alreadyDelivered = input.attempts.some( + (attempt) => + attempt.runId === input.run.id && + attempt.id !== input.run.activeAttemptId && + deliveredAttemptIds.has(attempt.id), ); if (alreadyDelivered) return []; return sameThread .filter( (source) => - source.ordinal < input.run.ordinal && + runRanAfter(input.run, source) && (source.restartCancelledBackgroundWork?.length ?? 0) > 0 && - !prompted.some((later) => later.ordinal > source.ordinal), + !prompted.some((later) => runRanAfter(later, source)), ) .reduce>( (work, source) => diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index c282fa7a26d6..e7cd4b9476a7 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; import { + MessageId, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -11,6 +12,7 @@ import { ThreadId, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerSettings from "../serverSettings.ts"; @@ -201,6 +203,7 @@ it.effect("prompts a settled thread's continuation with the note of its lost wor Layer.merge( Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(projection), + recoverDelegatedTask: () => Effect.void, dispatch: (command) => { commands.push(command); return Effect.succeed({} as never); @@ -246,6 +249,7 @@ it.effect("does not continue a failed run that lost background work", () => Layer.merge( Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(projection), + recoverDelegatedTask: () => Effect.void, dispatch: (command) => { commands.push(command); return Effect.succeed({} as never); @@ -328,6 +332,7 @@ it.effect("does not duplicate delivery and yields to newer user work or opt-out" >[0][] = []; const threads = Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(projection), + recoverDelegatedTask: () => Effect.void, dispatch: (command) => { commands.push(command); if (command.type === "message.dispatch") @@ -499,6 +504,7 @@ it.effect("does not cancel or resume a run that completes while shutdown intent ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(projection), + recoverDelegatedTask: () => Effect.void, dispatch: () => Effect.sync(() => { dispatched = true; @@ -511,3 +517,214 @@ it.effect("does not cancel or resume a run that completes while shutdown intent assert.isFalse(dispatched); }), ); + +const continuationTexts = (projection: OrchestrationV2ThreadProjection) => + Effect.gen(function* () { + const texts: Array = []; + yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( + Effect.provide( + Layer.merge( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(projection), + recoverDelegatedTask: () => Effect.void, + dispatch: (command) => { + if (command.type === "message.dispatch") texts.push(command.text); + return Effect.succeed({} as never); + }, + }), + ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), + ), + ), + ); + return texts; + }); + +const cutMidTurn = (extra: Record = {}) => { + const base = makeProjection(); + return { + ...base, + runs: [ + { + ...base.runs[0]!, + status: "cancelled", + userMessageId: MessageId.make("message:user"), + ...extra, + }, + ], + providerTurns: [{ ...base.providerTurns[0]!, status: "cancelled" }], + } as unknown as OrchestrationV2ThreadProjection; +}; + +const queuedFollowUp = { + id: RunId.make("run:queued"), + ordinal: 2, + providerInstanceId: instanceId, + providerThreadId, + status: "queued", + queueHeld: true, +}; + +it.effect("continues a cut run past queued follow-ups, which stay held", () => + Effect.gen(function* () { + const live = makeProjection(); + assert.equal( + restartContinuationRun({ + ...live, + runs: [...live.runs, queuedFollowUp], + } as unknown as OrchestrationV2ThreadProjection)?.id, + runId, + ); + const cut = cutMidTurn(); + const texts = yield* continuationTexts({ + ...cut, + runs: [...cut.runs, queuedFollowUp], + } as unknown as OrchestrationV2ThreadProjection); + assert.deepEqual(texts, ["Continue where you left off."]); + }), +); + +it.effect("continues a resumed queued run that ran after an earlier continuation", () => + Effect.gen(function* () { + // The continuation (ordinal 3) ran ahead of the held queue, then the user + // resumed the queued run (ordinal 1 here) and the server restarted again. + const finishedContinuation = { + id: RunId.make("run:earlier-continuation"), + ordinal: 3, + providerInstanceId: instanceId, + providerThreadId, + status: "completed", + completedAt: DateTime.makeUnsafe("2026-10-03T10:00:00.000Z"), + }; + const live = makeProjection(); + assert.equal( + restartContinuationRun({ + ...live, + runs: [...live.runs, finishedContinuation], + } as unknown as OrchestrationV2ThreadProjection)?.id, + runId, + ); + const cut = cutMidTurn({ completedAt: DateTime.makeUnsafe("2026-10-03T10:05:00.000Z") }); + const texts = yield* continuationTexts({ + ...cut, + runs: [...cut.runs, finishedContinuation], + } as unknown as OrchestrationV2ThreadProjection); + assert.deepEqual(texts, ["Continue where you left off."]); + }), +); + +it.effect("does not continue a run the user asked to stop before the restart", () => + Effect.gen(function* () { + const texts = yield* continuationTexts({ + ...cutMidTurn(), + turnItems: [{ id: "turn-item:interrupt", runId, type: "run_interrupt_request" }], + } as unknown as OrchestrationV2ThreadProjection); + assert.deepEqual(texts, []); + }), +); + +it.effect("does not continue a cut /compact or /logout turn", () => + Effect.gen(function* () { + for (const text of ["/compact", " /LOGOUT "]) { + const texts = yield* continuationTexts({ + ...cutMidTurn(), + messages: [{ id: MessageId.make("message:user"), text, attachments: [] }], + } as unknown as OrchestrationV2ThreadProjection); + assert.deepEqual(texts, [], text); + } + const texts = yield* continuationTexts({ + ...cutMidTurn(), + messages: [{ id: MessageId.make("message:user"), text: "/compact later", attachments: [] }], + } as unknown as OrchestrationV2ThreadProjection); + assert.lengthOf(texts, 1); + }), +); + +it.effect("tells a turn cut mid-way about the background work it lost", () => + Effect.gen(function* () { + const texts = yield* continuationTexts( + cutMidTurn({ + restartCancelledBackgroundWork: [{ kind: "subagent", label: "Background reviewer" }], + }), + ); + assert.lengthOf(texts, 1); + assert.include(texts[0]!, "Background reviewer"); + assert.isTrue(texts[0]!.endsWith("Continue where you left off.")); + }), +); + +it.effect( + "carries the note forward when its continuation was cut before reaching the provider", + () => + Effect.gen(function* () { + const base = makeProjection(); + const original = { + ...base.runs[0]!, + id: RunId.make("run:original"), + status: "completed", + activeAttemptId: RunAttemptId.make("attempt:original"), + restartCancelledBackgroundWork: [{ kind: "shell", label: "sleep 25 && echo DONE" }], + }; + const projection = { + ...base, + runs: [ + original, + { + ...base.runs[0]!, + ordinal: 2, + status: "cancelled", + restartContinuationOfRunId: original.id, + }, + ], + // The original turn settled; the continuation's attempt never started one. + providerTurns: [ + { + ...base.providerTurns[0]!, + runAttemptId: original.activeAttemptId, + status: "completed", + }, + ], + } as unknown as OrchestrationV2ThreadProjection; + const texts = yield* continuationTexts(projection); + assert.lengthOf(texts, 1); + assert.include(texts[0]!, "sleep 25 && echo DONE"); + assert.notInclude(texts[0]!, "Continue where"); + }), +); + +it.effect("prepares later threads' continuations when one thread fails", () => + Effect.gen(function* () { + const brokenThreadId = ThreadId.make("thread:broken"); + const writes: Parameters[0][] = []; + const recovery = yield* ProviderRuntimeRecovery.make.pipe( + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getRecoveryThreadIds: () => Effect.succeed([brokenThreadId, threadId]), + getRuntimeRecoveryProjection: (id) => + id === brokenThreadId + ? Effect.fail( + new ProjectionStore.ProjectionStoreThreadNotFoundError({ threadId: id }), + ) + : Effect.succeed(makeProjection()), + }), + Layer.mock(EventSink.EventSinkV2)({ + writeWithEffects: (input) => + Effect.sync(() => { + writes.push(input); + return []; + }), + }), + IdAllocator.layer, + Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({}), + Layer.mock(EffectOutbox.EffectOutboxV2)({}), + ), + ), + ); + yield* recovery.prepareForShutdown; + assert.deepEqual( + writes.map((write) => write.effects[0]?.request), + [{ type: "provider-runtime.continue", sourceRunId: runId }], + ); + }), +); diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index 216b830de41e..f91d1a1e5f73 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -1,3 +1,4 @@ +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { CommandId, @@ -11,12 +12,16 @@ import * as Effect from "effect/Effect"; import type { ProjectionRuntimeRecoveryState } from "./ProjectionStore.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { isNativeMaintenanceCommand } from "./Orchestrator.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import { isRestartNoteSource, restartCancelledBackgroundWorkNote, + restartContinuationNote, } from "./RestartBackgroundNote.ts"; +const CONTINUE_PROMPT = "Continue where you left off."; + /** * The run a restart continuation resumes, if any: an unfinished root run, or a * settled one whose own provider thread lost background work in the restart @@ -30,8 +35,12 @@ export function restartContinuationRun( cancelledWorkProviderThreadIds: ReadonlySet = new Set(), ): OrchestrationV2Run | undefined { if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) return; + // Queued runs never started; recovery holds them behind the cut run. const run = projection.runs.reduce( - (latest, candidate) => (!latest || candidate.ordinal > latest.ordinal ? candidate : latest), + (latest, candidate) => + candidate.status !== "queued" && (!latest || runRanAfter(candidate, latest)) + ? candidate + : latest, undefined, ); if (!run) return; @@ -98,7 +107,7 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta const messageId = MessageId.make(`message:restart-continuation:${input.sourceRunId}`); const projection = yield* threads.getThreadRecords( input.threadId, - ["messages", "runs", "providerTurns"], + ["messages", "runs", "providerTurns", "attempts"], { messageIds: [messageId] }, ); if ( @@ -114,17 +123,54 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta const noteSource = source !== undefined && isRestartNoteSource(source, projection.providerTurns); if (!source || (source.status !== "cancelled" && !noteSource)) return; - // A user submission after reconciliation takes precedence over an automatic prompt. - if (projection.runs.some((run) => run.ordinal > source.ordinal)) return; + // A user submission after reconciliation takes precedence over an automatic + // prompt. Queued runs never started and stay held behind this one. + if ( + projection.runs.some( + (run) => run.id !== source.id && run.status !== "queued" && runRanAfter(run, source), + ) + ) + return; if (projection.thread.providerInstanceId !== source.providerInstanceId) return; + const sourceRecords = yield* threads.getThreadRecords( + input.threadId, + ["messages", "turnItems"], + { + messageIds: [source.userMessageId], + turnItemRunIds: [source.id], + turnItemTypes: ["run_interrupt_request"], + }, + ); + // The user asked this run to stop before the restart cut it. + if ( + sourceRecords.turnItems.some( + (item) => item.runId === source.id && item.type === "run_interrupt_request", + ) + ) + return; + const sourceMessage = sourceRecords.messages.find( + (message) => message.id === source.userMessageId, + ); + if (sourceMessage !== undefined && isNativeMaintenanceCommand(sourceMessage)) return; + const note = restartContinuationNote( + source, + projection.runs, + projection.providerTurns, + projection.attempts, + ); + const noteText = + note.work.length === 0 ? undefined : restartCancelledBackgroundWorkNote(note.work); yield* threads.dispatch({ type: "message.dispatch", commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`), threadId: input.threadId, messageId, - text: noteSource - ? restartCancelledBackgroundWorkNote(source.restartCancelledBackgroundWork ?? []) - : "Continue where you left off.", + text: + noteText === undefined + ? CONTINUE_PROMPT + : note.settled + ? noteText + : `${noteText}\n\n${CONTINUE_PROMPT}`, attachments: [], modelSelection: source.modelSelection, dispatchMode: { type: "start_immediately" }, @@ -133,4 +179,15 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta restartContinuationOfRunId: input.sourceRunId, }); }, + // A delegated child this declined to continue still owes its parent a + // result. Once a continuation run exists this is a no-op; that run settles it. + (effect, input) => + effect.pipe( + Effect.andThen( + Effect.gen(function* () { + const threads = yield* ThreadManagementService.ThreadManagementService; + yield* threads.recoverDelegatedTask(input.threadId, input.sourceRunId); + }), + ), + ), ); diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index dac4345c92dc..23d180102cd3 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -166,6 +166,28 @@ function taskFixture() { return { projection: { ...projection, runs: [run] }, run }; } +it("reports the run that ended last, not the highest ordinal", () => { + const { projection, run } = taskFixture(); + // A restart continuation (ordinal 4) ran ahead of held queued runs 2 and 3. + const ended = (ordinal: number, completedAt: string): OrchestrationV2Run => ({ + ...run, + id: RunId.make(`run:${ordinal}`), + ordinal, + completedAt: DateTime.makeUnsafe(completedAt), + }); + const progress = delegatedTaskProgress({ + ...projection, + runs: [ + { ...run, status: "cancelled" }, + ended(4, "2026-07-24T10:00:00.000Z"), + ended(2, "2026-07-24T10:05:00.000Z"), + ended(3, "2026-07-24T10:10:00.000Z"), + ], + }); + assert.equal(progress.state, "result_available"); + assert.equal(progress.resultRun?.ordinal, 3); +}); + it("waits for nested work and retains the report across monitor acknowledgements", () => { const { projection, run } = taskFixture(); assert.equal( diff --git a/apps/server/src/orchestration-v2/SubagentProjection.ts b/apps/server/src/orchestration-v2/SubagentProjection.ts index 8b3abf78fe85..fa912dfda0ba 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.ts @@ -16,6 +16,7 @@ import type { ThreadId, TurnItemId, } from "@t3tools/contracts"; +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import * as DateTime from "effect/DateTime"; import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; @@ -239,7 +240,7 @@ export function delegatedTaskProgress(projection: { projection.providerThreads.some((thread) => (thread.pendingBackgroundTasks?.length ?? 0) > 0); const resultRun = workRuns .filter((run) => terminal(run.status) && (run.startedAt !== null || run.ordinal === 1)) - .toSorted((a, b) => b.ordinal - a.ordinal)[0]; + .toSorted((a, b) => (runRanAfter(a, b) ? -1 : runRanAfter(b, a) ? 1 : 0))[0]; return { state: active || resultRun === undefined diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 10eb4ac9485e..5e53250915f8 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -10,6 +10,7 @@ import * as ThreadMessageIntake from "./ThreadMessageIntake.ts"; import { assert, it, vi } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { + ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, ChatAttachmentId, ComposerContextId, type ChatAttachment, @@ -127,6 +128,10 @@ function makeHarness(options: HarnessOptions = {}) { const renameBranch = vi.fn( options.renameBranch ?? ((input) => Effect.succeed({ branch: input.newBranch })), ); + const removeWorktree = vi.fn( + (_input: Parameters[0]) => + Effect.void, + ); const runSetup = vi.fn( options.runSetup ?? (() => Effect.succeed({ status: "no-script" as const })), ); @@ -164,7 +169,7 @@ function makeHarness(options: HarnessOptions = {}) { fetchRemote: options.fetchRemote ?? (() => Effect.void), remoteExists: () => Effect.succeed(true), remoteBranchExists: () => Effect.succeed(true), - removeWorktree: () => Effect.void, + removeWorktree, resolveRemoteTrackingCommit: () => Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" }), }), @@ -220,6 +225,7 @@ function makeHarness(options: HarnessOptions = {}) { externalServices, ), createWorktree, + removeWorktree, renameBranch, generateBranchName, generateThreadTitle, @@ -1293,6 +1299,172 @@ it.effect("shows the fetch diagnosis when preparing a worktree from origin fails }).pipe(Effect.provide(harness.layer)); }); +it.effect("retries a failed workspace preparation on the same run", () => { + let fetchFailures = 1; + const harness = makeHarness({ + fetchRemote: () => + fetchFailures-- > 0 + ? Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.fetchRemote", + command: "git", + cwd: project.workspaceRoot, + detail: "Git could not update a local reference.", + exitCode: 1, + }), + ) + : Effect.void, + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:retry", + thread: "thread:launch:retry", + message: "Retry me", + workspace: { type: "worktree", baseRef: "main", startFromOrigin: true }, + }), + ); + yield* waitUntil(() => + threads + .getThreadProjection(launched.threadId) + .pipe(Effect.map((projection) => projection.runs[0]?.status === "failed")), + ); + const failed = yield* threads.getThreadProjection(launched.threadId); + const runId = failed.runs[0]!.id; + assert.equal( + failed.turnItems.find((item) => item.type === "error")?.failure.code, + ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, + ); + + const retry = { + commandId: CommandId.make("command:launch:retry:1"), + threadId: launched.threadId, + runId, + }; + yield* launches.retryPreparation(retry); + yield* waitUntil(() => + outbox + .listByCommandId(CommandId.make("command:launch:retry:1:release")) + .pipe(Effect.map((effects) => effects.length === 1)), + ); + const retried = yield* threads.getThreadProjection(launched.threadId); + assert.equal(retried.runs.length, 1); + assert.equal(retried.runs[0]?.status, "starting"); + assert.equal(retried.thread.worktreePath, "/repo-worktrees/feature"); + assert.equal(retried.turnItems.find((item) => item.type === "error")?.status, "cancelled"); + assert.equal( + retried.turnItems.find((item) => item.type === "command_execution")?.status, + "completed", + ); + assert.equal(harness.createWorktree.mock.calls.length, 1); + + // The run left preparation, so a second retry has nothing to do. + const rejected = yield* launches + .retryPreparation({ ...retry, commandId: CommandId.make("command:launch:retry:2") }) + .pipe(Effect.flip); + assert.equal(rejected._tag, "OrchestratorDispatchError"); + }).pipe(Effect.provide(harness.layer)); +}); + +it.effect("a retry reuses a recorded worktree without undoing its branch rename", () => { + let setupFailures = 1; + const harness = makeHarness({ + runSetup: () => + setupFailures-- > 0 + ? Effect.fail(new Error("setup failed") as never) + : Effect.succeed({ status: "no-script" as const }), + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const threads = yield* ThreadManagement.ThreadManagementService; + const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:reuse", + thread: "thread:launch:reuse", + message: "Reuse the worktree", + workspace: { type: "worktree", baseRef: "main" }, + }), + ); + yield* waitUntil(() => + threads + .getThreadProjection(launched.threadId) + .pipe( + Effect.map( + (projection) => + projection.runs[0]?.status === "failed" && + projection.thread.branch === "generated-branch", + ), + ), + ); + const failed = yield* threads.getThreadProjection(launched.threadId); + assert.equal(failed.thread.worktreePath, "/repo-worktrees/feature"); + + yield* launches.retryPreparation({ + commandId: CommandId.make("command:launch:reuse:retry"), + threadId: launched.threadId, + runId: failed.runs[0]!.id, + }); + yield* waitUntil(() => + outbox + .listByCommandId(CommandId.make("command:launch:reuse:retry:release")) + .pipe(Effect.map((effects) => effects.length === 1)), + ); + const retried = yield* threads.getThreadProjection(launched.threadId); + assert.equal(retried.runs[0]?.status, "starting"); + // The retry neither checks out again nor puts back the temporary branch. + assert.equal(harness.createWorktree.mock.calls.length, 1); + assert.equal(harness.renameBranch.mock.calls.length, 1); + assert.equal(retried.thread.branch, "generated-branch"); + assert.equal(retried.thread.worktreePath, "/repo-worktrees/feature"); + // Clients see the retry's setup, not the failed one it replaced. + const snapshot = yield* tracker.get(launched.threadId); + assert.equal(snapshot?.phase, "done"); + assert.deepEqual( + snapshot?.stages.map((stage) => stage.id), + ["setup-script", "agent"], + ); + }).pipe(Effect.provide(harness.layer)); +}); + +it.effect("removes a worktree that failed before the thread recorded it", () => { + const harness = makeHarness({ + // A checkout that dies after claiming its directory. + createWorktree: (_input, options) => + (options?.progress?.onWorktreeClaimed?.("/repo-worktrees/partial") ?? Effect.void).pipe( + Effect.andThen(Effect.fail(new Error("checkout failed") as never)), + ), + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:partial-worktree", + thread: "thread:launch:partial-worktree", + message: "Partial checkout", + workspace: { type: "worktree", baseRef: "main" }, + }), + ); + yield* waitUntil(() => + threads + .getThreadProjection(launched.threadId) + .pipe(Effect.map((projection) => projection.runs[0]?.status === "failed")), + ); + const projection = yield* threads.getThreadProjection(launched.threadId); + // Unrecorded, so a retry would create a second checkout beside it. + assert.equal(projection.thread.worktreePath, null); + assert.deepEqual( + harness.removeWorktree.mock.calls.map(([input]) => input.path), + ["/repo-worktrees/partial"], + ); + }).pipe(Effect.provide(harness.layer)); +}); + it.effect.each(["worktree", "setup"] as const)( "%s failure keeps the thread and message visible and emits failure items", (failurePoint) => @@ -1799,6 +1971,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch }), ), ), + retryPreparation: launches.retryPreparation, }), Effect.flip, ); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index faf613376920..555d3266eba2 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -39,6 +39,7 @@ import * as ServerSettings from "../serverSettings.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as IdAllocator from "./IdAllocator.ts"; +import type * as Orchestrator from "./Orchestrator.ts"; import { makeProviderFailure } from "./ProviderFailure.ts"; import { randomUuidV4 } from "./RandomUuid.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; @@ -90,6 +91,25 @@ export interface ThreadLaunchInput { readonly creationSource: OrchestrationV2CreationSource; } +/** What workspace preparation reads from a launch; a retry rebuilds it from the run. */ +type PreparationInput = Pick< + ThreadLaunchInput, + "commandId" | "projectId" | "workspaceStrategy" | "initialMessage" +> & { + /** + * Set when a retry reuses the worktree its failed attempt created and + * recorded. Its setup is tracked like a new one, but the thread already + * records the workspace, and a branch rename may still be running. + */ + readonly reusedWorktree?: { readonly baseRef: string }; +}; + +export interface ThreadLaunchRetryInput { + readonly commandId: CommandId; + readonly threadId: ThreadId; + readonly runId: RunId; +} + export interface ThreadLaunchResult { readonly threadId: ThreadId; readonly projection: OrchestrationV2ThreadProjection; @@ -128,6 +148,10 @@ export class ThreadLaunchService extends Context.Service< readonly launch: ( input: ThreadLaunchInput, ) => Effect.Effect; + /** Dispatches prepared-run.retry and prepares the run's workspace again. */ + readonly retryPreparation: ( + input: ThreadLaunchRetryInput, + ) => Effect.Effect; } >()("t3/orchestration-v2/ThreadLaunchService") {} @@ -161,7 +185,7 @@ const make = Effect.gen(function* () { yield* Effect.addFinalizer(() => Scope.close(preparationScope, Exit.void)); const mapError = - (input: ThreadLaunchInput, operation: ThreadLaunchError["operation"], threadId?: ThreadId) => + (input: PreparationInput, operation: ThreadLaunchError["operation"], threadId?: ThreadId) => (cause: unknown) => new ThreadLaunchError({ operation, @@ -201,7 +225,7 @@ const make = Effect.gen(function* () { }); const prepareInBackground = Effect.fn("ThreadLaunchService.prepareInBackground")(function* ( - input: ThreadLaunchInput, + input: PreparationInput, threadId: ThreadId, runId: RunId | null, ) { @@ -216,10 +240,12 @@ const make = Effect.gen(function* () { ), ); - const tracked = input.workspaceStrategy.type === "worktree"; + const reused = input.reusedWorktree; + const tracked = input.workspaceStrategy.type === "worktree" || reused !== undefined; let createdWorktreePath: string | null = null; let setupTerminalId: string | null = null; - if (tracked) { + let workspaceRecorded = false; + if (input.workspaceStrategy.type === "worktree") { yield* setupTracker.begin({ threadId, branch: input.workspaceStrategy.branch ?? null, @@ -227,6 +253,14 @@ const make = Effect.gen(function* () { stages: ["fetch", "checkout", "setup-script", "agent"], fiber: yield* Effect.fiber, }); + } else if (reused !== undefined) { + yield* setupTracker.begin({ + threadId, + branch: input.workspaceStrategy.branch ?? null, + baseRef: reused.baseRef, + stages: ["setup-script", "agent"], + fiber: yield* Effect.fiber, + }); } yield* Effect.gen(function* () { const initialMessage = input.initialMessage; @@ -358,21 +392,27 @@ const make = Effect.gen(function* () { yield* setupTracker.stageStatus(threadId, "checkout", "done"); } - yield* threads - .dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make(`${input.commandId}:workspace`), - threadId, - branch, - worktreePath, - }) - .pipe(Effect.mapError(mapError(input, "update-thread", threadId))); + // A reused worktree is already recorded, and rewriting it could undo + // the first attempt's branch rename. + if (reused === undefined) { + yield* threads + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`${input.commandId}:workspace`), + threadId, + branch, + worktreePath, + }) + .pipe(Effect.mapError(mapError(input, "update-thread", threadId))); + } + workspaceRecorded = true; // Rename temporary branches (server-invented above, or sent by clients // that name worktrees themselves) in the background so generation latency // never delays provisioning or the provider turn. The temporary name // simply sticks if generation or the rename fails. if ( + reused === undefined && worktreePath !== null && branch !== null && initialMessage !== undefined && @@ -512,27 +552,41 @@ const make = Effect.gen(function* () { cancelled ? "cancelled" : "failed", cancelled ? null : failureDetail(Cause.squash(cause)), ); - if (cancelled && tracked && createdWorktreePath) { + // A cancelled setup leaves nothing behind. A failed one keeps a worktree + // the thread recorded, so a retry reuses it, and removes one it never + // recorded, which a retry would otherwise duplicate. + if (tracked && createdWorktreePath && (cancelled || !workspaceRecorded)) { if (setupTerminalId) yield* terminals .close({ threadId, terminalId: setupTerminalId, deleteHistory: true }) .pipe(Effect.ignore); + const removedPath = createdWorktreePath; + // The thread forgets the worktree only once it is gone; a failed + // removal leaves the directory for the user to clean up rather than + // reusing a checkout that may be half written. yield* git - .removeWorktree({ - cwd: project.workspaceRoot, - path: createdWorktreePath, - force: true, - }) - .pipe(Effect.ignore); - yield* threads - .dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make(`${input.commandId}:cancel-workspace`), - threadId, - worktreePath: null, - branch: null, - }) - .pipe(Effect.ignore); + .removeWorktree({ cwd: project.workspaceRoot, path: removedPath, force: true }) + .pipe( + Effect.andThen( + threads + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`${input.commandId}:cancel-workspace`), + threadId, + worktreePath: null, + branch: null, + }) + .pipe(Effect.ignore), + ), + Effect.catchCause((removeCause) => + Effect.logWarning("Failed to remove an abandoned thread worktree", { + commandId: input.commandId, + threadId, + path: removedPath, + cause: removeCause, + }), + ), + ); } }), ), @@ -540,7 +594,7 @@ const make = Effect.gen(function* () { }); const failPreparedRun = ( - input: ThreadLaunchInput, + input: Pick, threadId: ThreadId, runId: RunId | null, cause: unknown, @@ -565,7 +619,6 @@ const make = Effect.gen(function* () { }), }) .pipe( - Effect.mapError(mapError(input, "fail-run", threadId)), Effect.catchCause((persistCause) => Effect.logWarning("Failed to persist thread workspace preparation failure", { commandId: input.commandId, @@ -592,7 +645,7 @@ const make = Effect.gen(function* () { }); const schedulePreparation = Effect.fn("ThreadLaunchService.schedulePreparation")(function* ( - input: ThreadLaunchInput, + input: PreparationInput, threadId: ThreadId, runId: RunId | null, ) { @@ -763,7 +816,7 @@ const make = Effect.gen(function* () { ...(input.initialMessage.context ? { context: input.initialMessage.context } : {}), ...(input.generateTitle === true ? { titleSeed: input.title } : {}), modelSelection: input.modelSelection, - dispatchMode: { type: "defer_start" }, + dispatchMode: { type: "defer_start", workspaceStrategy }, createdBy: input.createdBy, creationSource: input.creationSource, }) @@ -836,7 +889,78 @@ const make = Effect.gen(function* () { }, ); - return ThreadLaunchService.of({ launch }); + const retryPreparation: ThreadLaunchService["Service"]["retryPreparation"] = Effect.fn( + "ThreadLaunchService.retryPreparation", + )(function* (input) { + const dispatched = yield* threads.dispatch({ + type: "prepared-run.retry", + commandId: input.commandId, + threadId: input.threadId, + runId: input.runId, + }); + // A replayed retry finds the run already past preparation, or prepared by + // the attempt that first reserved this command. + // From here the run is preparing again; anything that stops preparation + // from being scheduled must fail it, or it would wait in preparing forever. + const scheduled = yield* Effect.gen(function* () { + const projection = yield* threads.getThreadProjection(input.threadId); + const run = projection.runs.find((candidate) => candidate.id === input.runId); + const workspacePreparation = run?.workspacePreparation; + if (run?.status !== "preparing" || workspacePreparation === undefined) return; + if (!(yield* reservePreparation(input.commandId))) return; + yield* scheduleRetriedPreparation(input, projection, run, workspacePreparation).pipe( + Effect.onError(() => releasePreparation(input.commandId)), + ); + }).pipe(Effect.exit); + if (Exit.isFailure(scheduled)) { + yield* failPreparedRun(input, input.threadId, input.runId, Cause.squash(scheduled.cause)); + } + return dispatched; + }); + + const scheduleRetriedPreparation = ( + input: ThreadLaunchRetryInput, + projection: OrchestrationV2ThreadProjection, + run: OrchestrationV2ThreadProjection["runs"][number], + workspacePreparation: ThreadLaunchWorkspaceStrategy, + ) => { + const message = projection.messages.find((candidate) => candidate.id === run.userMessageId); + // A worktree the failed attempt already created is reused, not created again. + const reuse = + workspacePreparation.type === "worktree" && + projection.thread.worktreePath !== null && + projection.thread.branch !== null + ? { + strategy: { + type: "existing_worktree" as const, + worktreePath: projection.thread.worktreePath, + branch: projection.thread.branch, + }, + reusedWorktree: { baseRef: workspacePreparation.baseRef }, + } + : null; + return schedulePreparation( + { + commandId: input.commandId, + projectId: projection.thread.projectId, + workspaceStrategy: reuse?.strategy ?? workspacePreparation, + ...(reuse === null ? {} : { reusedWorktree: reuse.reusedWorktree }), + ...(message === undefined + ? {} + : { + initialMessage: { + text: message.text, + attachments: message.attachments, + ...(message.context ? { context: message.context } : {}), + }, + }), + }, + input.threadId, + run.id, + ); + }; + + return ThreadLaunchService.of({ launch, retryPreparation }); }); export const layer = Layer.effect(ThreadLaunchService, make); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index b489f6890967..3bb397728060 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -313,6 +313,8 @@ export interface ThreadManagementServiceShape { input: ThreadManagementInterruptInput, ) => Effect.Effect; readonly getThreadEventSequence: Orchestrator.OrchestratorV2["Service"]["getThreadEventSequence"]; + readonly recoverDelegatedTask: Orchestrator.OrchestratorV2["Service"]["recoverDelegatedTask"]; + readonly delegatedTaskResultPending: Orchestrator.OrchestratorV2["Service"]["delegatedTaskResultPending"]; readonly streamStoredEvents: Orchestrator.OrchestratorV2["Service"]["streamStoredEvents"]; readonly streamStoredEventsFrom: Orchestrator.OrchestratorV2["Service"]["streamStoredEventsFrom"]; readonly streamDomainEvents: Orchestrator.OrchestratorV2["Service"]["streamDomainEvents"]; @@ -736,6 +738,8 @@ const make = Effect.gen(function* () { waitForThread, interruptThread, getThreadEventSequence: orchestrator.getThreadEventSequence, + recoverDelegatedTask: orchestrator.recoverDelegatedTask, + delegatedTaskResultPending: orchestrator.delegatedTaskResultPending, streamStoredEvents: orchestrator.streamStoredEvents, streamStoredEventsFrom: orchestrator.streamStoredEventsFrom, streamDomainEvents: orchestrator.streamDomainEvents, diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 03933d9d0048..ef12204541b6 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -46,7 +46,12 @@ function at(offsetMs: number): DateTime.Utc { return DateTime.makeUnsafe(NOW_MS + offsetMs); } -function shell(overrides: Partial = {}): OrchestrationV2ThreadShell { +type SettlementShell = OrchestrationV2ThreadShell & + Pick; + +// A fixture's user message is one the user wrote unless the test sets +// latestUserAuthoredMessageAt on its own. +function shell(overrides: Partial = {}): SettlementShell { return { id: ThreadId.make("thread-1"), projectId: ProjectId.make("project-1"), @@ -83,6 +88,7 @@ function shell(overrides: Partial = {}): Orchestrati latestRunStartedAt: null, latestRunCompletedAt: null, latestUserMessageAt: null, + latestUserAuthoredMessageAt: overrides.latestUserMessageAt ?? null, createdAt: at(-30 * DAY_MS), updatedAt: at(-10 * DAY_MS), archivedAt: null, @@ -138,12 +144,25 @@ describe("isAutoSettlementCandidate", () => { ).toBe(false); expect( ThreadSettlementService.isAutoSettlementCandidate( - shell({ pendingBackgroundTasks: [{ label: "task" }] as never }), + shell({ pendingBackgroundTasks: [{ taskId: "review", kind: "subagent" }] }), NOW_MS, ), ).toBe(false); }); + it("settles a thread whose only background work is a command left running", () => { + expect( + ThreadSettlementService.isAutoSettlementCandidate( + shell({ + pendingBackgroundTasks: [ + { taskId: "dev", kind: "command", description: "vp run dev --share" }, + ], + }), + NOW_MS, + ), + ).toBe(true); + }); + it("keeps snoozed threads parked until they wake early on error or completion", () => { const snoozed = shell({ snoozedUntil: at(60 * 60 * 1_000), @@ -292,6 +311,31 @@ describe("resolveAutoSettlementAt", () => { ).toEqual(shell().createdAt); }); + it("settles on merge after agent-started runs, but not after the user writes again", () => { + // A background command stopped after the merge and its notification + // started a run. Only the user's own messages hold a merged thread open. + const woken = shell({ + latestUserMessageAt: at(-30 * 60 * 1_000), + latestUserAuthoredMessageAt: at(-2 * 60 * 60 * 1_000), + latestRunRequestedAt: at(-30 * 60 * 1_000), + latestRunCompletedAt: at(-29 * 60 * 1_000), + }); + const input = { + thread: woken, + pullRequest: { state: "merged" as const, mergedAt: DateTime.formatIso(at(-60 * 60 * 1_000)) }, + nowMs: NOW_MS, + autoSettleAfterDays: null, + autoSettleOnMerge: true, + }; + expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toEqual(at(-29 * 60 * 1_000)); + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + ...input, + thread: { ...woken, latestUserAuthoredMessageAt: at(-30 * 60 * 1_000) }, + }), + ).toBeNull(); + }); + it("settles inactive threads even when their pull request remains open", () => { const input = { thread: shell({ latestUserMessageAt: at(-30 * DAY_MS) }), @@ -370,10 +414,7 @@ function makeProject( }; } -function makeThread( - id: string, - overrides: Partial = {}, -): OrchestrationV2ThreadShell { +function makeThread(id: string, overrides: Partial = {}): SettlementShell { return shell({ id: ThreadId.make(id), projectId: PROJECT_ID, @@ -385,10 +426,14 @@ function makeThread( }); } +type SettlementSnapshot = Omit & { + readonly threads: ReadonlyArray; +}; + function makeSnapshot( - threads: ReadonlyArray, + threads: ReadonlyArray, projects: ReadonlyArray = [makeProject()], -): OrchestrationV2ShellSnapshot { +): SettlementSnapshot { return { schemaVersion: 1, snapshotSequence: 1, @@ -435,7 +480,7 @@ function makeBranchPullRequest(state: "open" | "closed" | "merged") { } interface HarnessOptions { - readonly snapshot: OrchestrationV2ShellSnapshot; + readonly snapshot: SettlementSnapshot; readonly settings?: ContractServerSettings; readonly branchPullRequest?: GitManager.GitManager["Service"]["branchPullRequest"]; readonly pullRequestSummary?: PullRequestService.PullRequestService["Service"]["summary"]; diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 74c9de8d6b37..7c64ce02804a 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -1,3 +1,4 @@ +import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import { @@ -105,10 +106,15 @@ export function threadHasQueuedTurnStart( ].every((value) => value === null || value < messageAtMs); } +/** + * A merged or closed pull request settles the thread unless the user wrote to + * it afterwards. Runs that background work, a PR watch, or another agent + * started do not count, so they cannot hold a merged thread open. + */ function pullRequestSettles( thread: Pick< - OrchestrationV2ThreadShell, - "createdAt" | "latestUserMessageAt" | "latestRunRequestedAt" + ProjectionStore.ProjectionSettlementCandidate, + "createdAt" | "latestUserAuthoredMessageAt" >, pullRequest: SettlementPullRequest, autoSettleOnMerge: boolean, @@ -120,8 +126,7 @@ function pullRequestSettles( if (terminalAt == null) return false; const userAnchorMs = latestMillis([ toMillis(thread.createdAt), - toMillis(thread.latestUserMessageAt), - toMillis(thread.latestRunRequestedAt), + toMillis(thread.latestUserAuthoredMessageAt), ]); if (userAnchorMs === null) return false; const pullRequestAtMs = Date.parse(terminalAt); @@ -131,16 +136,17 @@ function pullRequestSettles( /** Cheap checks that run before any source control lookup. */ export function isAutoSettlementCandidate( - thread: ProjectionStore.ProjectionSettlementCandidate, + thread: Omit, nowMs: number, ): boolean { if (thread.archivedAt !== null || thread.settledOverride !== null) return false; if (thread.pinnedAt != null || thread.autoSettleDisabledAt != null) return false; // Blocked-on-you work must never park behind a settled override. if (thread.pendingRuntimeRequest !== null) return false; - // A live run — or post-settlement background work — is not staleness. + // A live run, or background work that will wake the agent, is not + // staleness. A dev server left running is: the agent is done. if (thread.activityRunStatus != null) return false; - if ((thread.pendingBackgroundTasks?.length ?? 0) > 0) return false; + if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) return false; if (threadHasQueuedTurnStart(thread, nowMs)) return false; const snoozedUntilMs = toMillis(thread.snoozedUntil); if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return true; diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index b9cad107409a..0bb2d5053b4b 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -165,6 +165,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray( dispatch: orchestrator.dispatch, getThreadRecords: orchestrator.getThreadRecords, getThreadProjection: orchestrator.getThreadProjection, + recoverDelegatedTask: orchestrator.recoverDelegatedTask, + delegatedTaskResultPending: orchestrator.delegatedTaskResultPending, }); }), ).pipe(Layer.provide(orchestratorProvided)); @@ -497,6 +499,8 @@ export function makeOrchestratorV2ReplayLayerWithRegistry( Orchestrator.OrchestratorV2, Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; + // As in serverRuntimeStartup: after runtime recovery, before the worker. + yield* orchestrator.recoverDelegatedTasks; yield* EffectWorker.runDaemon.pipe(Effect.forkScoped); return orchestrator; }), diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts index 0828f7f9bbec..4c91460bed69 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts @@ -180,6 +180,9 @@ export function assertClaudeBackgroundSubagentLifecycleOutput( const agentAChild = agentA?.childThreadId == null ? undefined : result.projections.get(agentA.childThreadId); assert.isDefined(agentAChild); + // The thread starts on the Agent call's "haiku"; the snapshot's model, which + // only arrives with the subagent's first reply, replaces it. + assert.equal(agentAChild.thread.modelSelection.model, AGENT_A_OBSERVED_MODEL); assert.deepEqual(assistantTexts(agentAChild), ["A_FIRST", "A_SECOND"]); assert.deepEqual(conversation(agentAChild), [ "user:Reply with exactly: A_FIRST", diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index cccc826478b7..9c1115ca0df7 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -209,6 +209,36 @@ describe("threadHistoryPaging", () => { ); }); + it("pages agent-only child transcripts instead of dropping their earlier activity", () => { + const commandRows = Array.from({ length: 90 }, (_, index) => makeRow(index + 1)); + const first = makeRow(0); + const prompt = { + ...first, + item: { + ...first.item, + type: "user_message" as const, + createdBy: "agent" as const, + creationSource: "provider" as const, + inputIntent: "turn_start" as const, + messageId: MessageId.make("child-prompt"), + text: "Inspect this project", + attachments: [], + }, + } as OrchestrationV2ProjectedTurnItem; + const items = [prompt, ...commandRows]; + const recent = selectRecentTimelineWindow({ items, snapshotSequence: 1 }); + + expect(recent.items).toHaveLength(THREAD_HISTORY_PAGE_POLICY.maxItems); + expect(recent.hasMoreHistory).toBe(true); + const older = selectHistoryPageFromCursor({ + items, + cursor: recent.nextCursor!, + snapshotSequence: 1, + }); + expect(older.items[0]?.item.type).toBe("user_message"); + expect([...older.items, ...recent.items]).toHaveLength(items.length); + }); + it("encodes opaque cursors with stable source identity", () => { const cursor = encodeThreadHistoryCursor({ snapshotSequence: 9, diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index 98ae11afdad0..a2e6c87e20df 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -185,7 +185,7 @@ function selectOlderTimelinePage(input: { let encodedBytes = 0; let userTurns = 0; let rawTurns = 0; - const turnLimit = input.items.slice(0, end).some((row) => isThreadHistoryTurnStart(row.item)) + const turnLimit = input.items.slice(0, end).some((row) => isThreadHistoryUserTurn(row.item)) ? policy.maxUserTurns : undefined; for (let index = end - 1; index >= 0; index -= 1) { diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index b11ba6e20563..2cdf78c56a14 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -117,6 +117,7 @@ export function azureDevOpsProviderFailure( if (error._tag === "AzureDevOpsCliUnavailableError") return { reason: "missing-tool" }; if (error._tag === "AzureDevOpsCliAuthenticationError") return { reason: "unauthenticated" }; if (error._tag === "AzureDevOpsCliRateLimitError") return { reason: "rate-limited" }; + if (error._tag === "AzureDevOpsPullRequestNotFoundError") return { reason: "not-found" }; return { reason: "failed" }; } diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index e0ee4c0a85b7..f553dfd13bc1 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -82,6 +82,12 @@ export function bitbucketProviderFailure( ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), }; } + if ( + (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") && + error.status === 404 + ) { + return { reason: "not-found" }; + } return { reason: "failed" }; } diff --git a/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts b/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts index 73d582c9042c..12d931279a74 100644 --- a/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts +++ b/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts @@ -89,7 +89,9 @@ export const make = Effect.gen(function* () { ? "unauthenticated" : error.reason === "rate-limit" ? "rate-limited" - : "failed", + : error.reason === "not-found" + ? "not-found" + : "failed", }), ), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 5e70c2ee1ed4..a51825c2e11b 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -115,6 +115,7 @@ export function gitHubProviderFailure( if (error._tag === "SourceControlRateLimitPausedError") { return { reason: "rate-limited", retryAt: error.retryAt }; } + if (error._tag === "GitHubPullRequestNotFoundError") return { reason: "not-found" }; return { reason: "failed" }; } diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts index ad089382352a..45034b84787c 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts @@ -102,6 +102,7 @@ export function gitLabProviderFailure( if (error._tag === "GitLabCliUnavailableError") return { reason: "missing-tool" }; if (error._tag === "GitLabCliAuthenticationError") return { reason: "unauthenticated" }; if (error._tag === "GitLabCliRateLimitError") return { reason: "rate-limited" }; + if (error._tag === "GitLabMergeRequestNotFoundError") return { reason: "not-found" }; return { reason: "failed" }; } diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index cc370637c601..f13af63304d2 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -52,7 +52,13 @@ export class PullRequestProviderError extends Schema.TaggedError + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 121 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + Effect.fail( + new PullRequestProviderError({ + provider: "github", + operation: "getChangeRequest", + reason: "not-found", + detail: "Pull request not found. Check the PR number or URL and try again.", + }), + ), + }), + ], + }); + + const error = yield* Effect.flip(service.detail(reference)); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual(error._tag === "PullRequestOperationError" && error.reason, "not-found"); + }), +); + it.effect("keeps recent detail on a transient refresh failure but not after invalidation", () => Effect.gen(function* () { let failing = false; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 293318f91c4b..ec523d398237 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -477,7 +477,12 @@ function toPullRequestError( return (error) => isProviderUnusable(error) ? toUnavailableError(error) - : new PullRequestOperationError({ operation, detail: error.detail, cause: error }); + : new PullRequestOperationError({ + operation, + detail: error.detail, + ...(error.reason === "not-found" ? { reason: "not-found" as const } : {}), + cause: error, + }); } function withRateLimitBackoff( diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index a90226f67c7e..003403fc5c09 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -206,6 +206,8 @@ const makeTestRelay = Effect.fnUntraced(function* ( waitForThread: unused, interruptThread: unused, getThreadEventSequence: unused, + recoverDelegatedTask: unused, + delegatedTaskResultPending: unused, streamStoredEvents: Stream.empty, streamStoredEventsFrom: () => Stream.empty, streamDomainEvents: options.domainEvents ?? Stream.empty, diff --git a/apps/server/src/scheduling/Scheduler.test.ts b/apps/server/src/scheduling/Scheduler.test.ts index dd3ac3948a12..fbbf15ced75e 100644 --- a/apps/server/src/scheduling/Scheduler.test.ts +++ b/apps/server/src/scheduling/Scheduler.test.ts @@ -6,6 +6,7 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as TestClock from "effect/testing/TestClock"; +import * as ServerActivation from "../serverActivation.ts"; import * as Scheduler from "./Scheduler.ts"; it.effect("keeps other sources running after a source defects", () => @@ -84,3 +85,26 @@ it.effect("unregisters closed sources and interrupts their in-flight work", () = assert.equal(yield* Ref.get(runs), 1); }).pipe(Effect.provide(Scheduler.layer)), ); + +it.effect("holds due work and the clock until startup recovery activates the server", () => + Effect.gen(function* () { + const activation = yield* Deferred.make(); + const runs = yield* Ref.make(0); + const ran = yield* Deferred.make(); + yield* Effect.gen(function* () { + const scheduler = yield* Scheduler.Scheduler; + yield* scheduler.register( + "due", + Ref.update(runs, (n) => n + 1).pipe(Effect.andThen(Deferred.succeed(ran, undefined))), + ); + yield* TestClock.adjust("10 seconds"); + assert.equal(yield* Ref.get(runs), 0); + yield* Deferred.succeed(activation, undefined); + yield* Deferred.await(ran); + }).pipe( + Effect.provide(Scheduler.layer), + Effect.provideService(ServerActivation.ServerActivation, Deferred.await(activation)), + ); + assert.isAtLeast(yield* Ref.get(runs), 1); + }), +); diff --git a/apps/server/src/scheduling/Scheduler.ts b/apps/server/src/scheduling/Scheduler.ts index 957ba11e2ea0..e2214094cffb 100644 --- a/apps/server/src/scheduling/Scheduler.ts +++ b/apps/server/src/scheduling/Scheduler.ts @@ -6,6 +6,8 @@ import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; +import { forkParked } from "../serverActivation.ts"; + /** Sources load due work from their durable state; registration owns its execution lifetime. */ export class Scheduler extends Context.Service< Scheduler, @@ -47,14 +49,15 @@ const make = Effect.gen(function* () { return next; }), ); - yield* run; + // Due work waits for startup recovery, which would cancel runs it started. + yield* forkParked(run); }); const tick = Ref.get(sources).pipe( Effect.flatMap((current) => Effect.forEach(current.values(), (run) => run, { discard: true })), ); // One clock for all due-work sources. A slow source cannot block another // source or overlap itself, and no extra missed-tick backlog is queued. - yield* Effect.sleep("5 seconds").pipe(Effect.andThen(tick), Effect.forever, Effect.forkScoped); + yield* forkParked(Effect.sleep("5 seconds").pipe(Effect.andThen(tick), Effect.forever)); return Scheduler.of({ register }); }); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index a89d010fab75..9aa6ebfaa45f 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -31,11 +31,20 @@ it.effect("starts without scanning or rebuilding projection history", () => const result = yield* ServerRuntimeStartup.runOrderedV2StartupPhases({ importLegacyShells: record("import"), recover: record("recover").pipe(Effect.as({ closedRequests: 2 })), + recoverDelegatedTasks: record("delegated"), startEffectWorker: record("worker"), autoBootstrap: record("bootstrap").pipe(Effect.as({ projectId: "project-1" })), }); - assert.deepEqual(yield* Ref.get(calls), ["import", "recover", "worker", "bootstrap"]); + // Delegated recovery reads the runs recovery terminalizes, and settles them + // before the worker runs restart continuations that would otherwise race it. + assert.deepEqual(yield* Ref.get(calls), [ + "import", + "recover", + "delegated", + "worker", + "bootstrap", + ]); assert.deepEqual(result, { recovery: { closedRequests: 2 }, bootstrap: { projectId: "project-1" }, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index e3f92058e70c..c06ff1159d87 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -32,6 +32,7 @@ import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as EffectWorker from "./orchestration-v2/EffectWorker.ts"; import * as LegacyV1ThreadImporter from "./orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; +import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as ProviderRuntimeRecovery from "./orchestration-v2/ProviderRuntimeRecoveryService.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import * as ThreadLaunch from "./orchestration-v2/ThreadLaunchService.ts"; @@ -386,21 +387,26 @@ export function runOrderedV2StartupPhases< Bootstrap, ImportError, RecoveryError, + DelegationError, WorkerError, BootstrapError, ImportContext, RecoveryContext, + DelegationContext, WorkerContext, BootstrapContext, >(input: { readonly importLegacyShells: Effect.Effect; readonly recover: Effect.Effect; + /** Settles delegated tasks whose runs recovery just terminalized. */ + readonly recoverDelegatedTasks: Effect.Effect; readonly startEffectWorker: Effect.Effect; readonly autoBootstrap: Effect.Effect; }) { return Effect.gen(function* () { yield* input.importLegacyShells; const recovery = yield* input.recover; + yield* input.recoverDelegatedTasks; yield* input.startEffectWorker; const bootstrap = yield* input.autoBootstrap; return { recovery, bootstrap } as const; @@ -413,6 +419,7 @@ const make = (options?: StartupOptions) => const keybindings = yield* Keybindings.Keybindings; const legacyV1ThreadImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; const providerRuntimeRecovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; + const orchestrator = yield* Orchestrator.OrchestratorV2; const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; @@ -509,6 +516,10 @@ const make = (options?: StartupOptions) => ), ), recover: runStartupPhase("orchestration-v2.recovery", providerRuntimeRecovery.recover), + recoverDelegatedTasks: runStartupPhase( + "orchestration-v2.delegated-tasks.recover", + orchestrator.recoverDelegatedTasks, + ), startEffectWorker: runStartupPhase( "orchestration-v2.effect-worker.start", startEffectWorkerWithRelay({ diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 8701e6c89162..2ee53fee4c20 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -23,10 +23,12 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; +import { writeFileStringAtomically } from "./atomicWrite.ts"; import * as ServerSettingsModule from "./serverSettings.ts"; import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; @@ -131,6 +133,114 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("saves through a symlinked settings file without replacing the link", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const service = yield* ServerSettingsModule.ServerSettingsService; + const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); + const linkedSettingsPath = path.join(dotfiles, "settings.json"); + yield* fs.writeFileString(linkedSettingsPath, `{ "responseStreamingMode": "turn" }`); + yield* fs.remove(config.settingsPath, { force: true }); + yield* fs.symlink(linkedSettingsPath, config.settingsPath); + + yield* service.updateSettings({ responseStreamingMode: "paragraph" }); + + assert.equal(yield* fs.readLink(config.settingsPath), linkedSettingsPath); + const persisted = yield* decodeServerSettingsJson( + yield* fs.readFileString(linkedSettingsPath), + ); + assert.equal(persisted.responseStreamingMode, "paragraph"); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("reloads when the destination of a symlinked settings file changes", () => + Effect.scoped( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const service = yield* ServerSettingsModule.ServerSettingsService; + const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); + const linkedSettingsPath = path.join(dotfiles, "settings.json"); + yield* fs.writeFileString(linkedSettingsPath, `{ "responseStreamingMode": "turn" }`); + yield* fs.remove(config.settingsPath, { force: true }); + yield* fs.symlink(linkedSettingsPath, config.settingsPath); + yield* service.start; + const changes = yield* service.subscribeChanges; + + yield* writeFileStringAtomically({ + filePath: linkedSettingsPath, + contents: `{ "responseStreamingMode": "paragraph" }`, + }); + + const change = yield* changes.pipe(Stream.runHead, Effect.timeout("2 seconds")); + assert.equal(Option.getOrUndefined(change)?.responseStreamingMode, "paragraph"); + }), + ).pipe(TestClock.withLive, Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("follows a settings link that is repointed to another directory", () => + Effect.scoped( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const service = yield* ServerSettingsModule.ServerSettingsService; + const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); + const firstSettingsPath = path.join(dotfiles, "first", "settings.json"); + const secondSettingsPath = path.join(dotfiles, "second", "settings.json"); + yield* fs.makeDirectory(path.dirname(firstSettingsPath), { recursive: true }); + yield* fs.makeDirectory(path.dirname(secondSettingsPath), { recursive: true }); + yield* fs.writeFileString(firstSettingsPath, `{ "responseStreamingMode": "turn" }`); + yield* fs.writeFileString(secondSettingsPath, `{ "responseStreamingMode": "paragraph" }`); + yield* fs.remove(config.settingsPath, { force: true }); + yield* fs.symlink(firstSettingsPath, config.settingsPath); + yield* service.start; + + const repointChanges = yield* service.subscribeChanges; + yield* fs.remove(config.settingsPath); + yield* fs.symlink(secondSettingsPath, config.settingsPath); + const repointed = yield* repointChanges.pipe(Stream.runHead, Effect.timeout("2 seconds")); + assert.equal(Option.getOrUndefined(repointed)?.responseStreamingMode, "paragraph"); + + const editChanges = yield* service.subscribeChanges; + yield* writeFileStringAtomically({ + filePath: secondSettingsPath, + contents: `{ "responseStreamingMode": "turn" }`, + }); + const edited = yield* editChanges.pipe(Stream.runHead, Effect.timeout("2 seconds")); + assert.equal(Option.getOrUndefined(edited)?.responseStreamingMode, "turn"); + }), + ).pipe(TestClock.withLive, Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("reloads when a dangling settings link gets its destination", () => + Effect.scoped( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const service = yield* ServerSettingsModule.ServerSettingsService; + const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); + const linkedSettingsPath = path.join(dotfiles, "not-yet", "settings.json"); + yield* fs.remove(config.settingsPath, { force: true }); + yield* fs.symlink(linkedSettingsPath, config.settingsPath); + yield* service.start; + const changes = yield* service.subscribeChanges; + + yield* writeFileStringAtomically({ + filePath: linkedSettingsPath, + contents: `{ "responseStreamingMode": "paragraph" }`, + }); + + const change = yield* changes.pipe(Stream.runHead, Effect.timeout("2 seconds")); + assert.equal(Option.getOrUndefined(change)?.responseStreamingMode, "paragraph"); + }), + ).pipe(TestClock.withLive, Effect.provide(makeServerSettingsLayer())), + ); + it.effect("preserves context when reading a provider environment secret fails", () => { const platformCause = PlatformError.systemError({ _tag: "PermissionDenied", diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index a0fb1ec0fb82..02cecc419b83 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -52,6 +52,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { writeFileStringAtomically } from "./atomicWrite.ts"; +import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; import * as ServerConfig from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; @@ -1222,10 +1223,24 @@ const make = Effect.gen(function* () { }), ); + const watchFileChanges = (filePath: string) => { + const directory = pathService.dirname(filePath); + const fileName = pathService.basename(filePath); + const resolvedFilePath = pathService.resolve(filePath); + return fs + .watch(directory) + .pipe( + Stream.filter( + (event) => + event.path === fileName || + event.path === filePath || + pathService.resolve(directory, event.path) === resolvedFilePath, + ), + ); + }; + const startWatcher = Effect.gen(function* () { const settingsDir = pathService.dirname(settingsPath); - const settingsFile = pathService.basename(settingsPath); - const settingsPathResolved = pathService.resolve(settingsPath); yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe( Effect.mapError( @@ -1240,19 +1255,43 @@ const make = Effect.gen(function* () { const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); + // A symlinked settings file is rewritten in its destination's directory, + // which a watch on the link's directory never sees. The link is resolved + // again whenever it changes, so repointing it moves the watch along. + const watchLinkTarget = Effect.gen(function* () { + const linkTargetPath = yield* resolveSymlinkTarget(settingsPath).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, pathService), + ); + if (linkTargetPath === pathService.resolve(settingsPath)) { + return Option.none(); + } + yield* fs + .makeDirectory(pathService.dirname(linkTargetPath), { recursive: true }) + .pipe(Effect.ignore({ log: true })); + return Option.some(linkTargetPath); + }).pipe(Effect.orElseSucceed(() => Option.none())); + + const initialLinkTarget = yield* watchLinkTarget; + const linkTargetEvents = Stream.make(initialLinkTarget).pipe( + Stream.concat(watchFileChanges(settingsPath).pipe(Stream.mapEffect(() => watchLinkTarget))), + Stream.changes, + Stream.switchMap( + Option.match({ + onNone: () => Stream.empty, + onSome: (linkTargetPath) => + watchFileChanges(linkTargetPath).pipe(Stream.ignore({ log: true })), + }), + ), + ); + // Debounce watch events so the file is fully written before we read it. // Editors emit multiple events per save (truncate, write, rename) and // `fs.watch` can fire before the content has been flushed to disk. - const debouncedSettingsEvents = fs.watch(settingsDir).pipe( - Stream.filter((event) => { - return ( - event.path === settingsFile || - event.path === settingsPath || - pathService.resolve(settingsDir, event.path) === settingsPathResolved - ); - }), - Stream.debounce(Duration.millis(100)), - ); + const debouncedSettingsEvents = Stream.merge( + watchFileChanges(settingsPath), + linkTargetEvents, + ).pipe(Stream.debounce(Duration.millis(100))); yield* Stream.runForEach(debouncedSettingsEvents, () => revalidateAndEmitSafely).pipe( Effect.ignoreCause({ log: true }), diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index dc3121fc0024..ca56c36028bb 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -53,7 +53,7 @@ import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEn import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; import { readAntigravityUsage } from "./antigravityUsageReader.ts"; import { readCursorAccountUsage } from "./cursorUsageReader.ts"; -import { UsageAggregator } from "./usageAggregation.ts"; +import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, @@ -821,6 +821,7 @@ export const make = Effect.gen(function* () { ...hourlyWindow, rates, priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), + modelAliases: resolveModelAliases(settings.usageModelAliases), }); const sources: UsageSource[] = []; @@ -925,17 +926,13 @@ export const make = Effect.gen(function* () { }); /** - * In-flight scans by window and custom prices, so concurrent identical requests (the usage + * In-flight scans by window and usage settings, so concurrent identical requests (the usage * page open on two clients at once) share one scan instead of racing over * the same corpus twice. */ const inflightScans = new Map>(); - const scanKey = ( - input: UsageSummaryInput, - priceOverrides: ServerSettingsValue["usagePriceOverrides"], - cursorKeychainUsageEnabled: boolean, - ): string => + const scanKey = (input: UsageSummaryInput, settings: ServerSettingsValue): string => JSON.stringify([ input.timeZone, input.sinceDay, @@ -943,13 +940,14 @@ export const make = Effect.gen(function* () { input.resolution ?? "day", input.sinceTime ?? null, input.untilTime ?? null, - priceOverrides, - cursorKeychainUsageEnabled, + settings.usagePriceOverrides, + settings.usageModelAliases, + settings.cursorKeychainUsageEnabled, ]); const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { const settings = yield* readSettings; - const key = scanKey(input, settings.usagePriceOverrides, settings.cursorKeychainUsageEnabled); + const key = scanKey(input, settings); const deferred = yield* Effect.uninterruptible( Effect.gen(function* () { const existing = inflightScans.get(key); diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 9fea9cd96644..a27a9b101cc3 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { UsageAggregator } from "./usageAggregation.ts"; +import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import type { RateTable } from "./usagePricing.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -253,6 +253,25 @@ describe("UsageAggregator", () => { expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false); }); + it("folds a mapped model into its target and prices it there", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + modelAliases: resolveModelAliases({ "example-preview": "claude-fable-5" }), + }); + aggregator.add(record()); + aggregator.add(record({ model: "example-preview", rateModel: "example-preview-high" })); + const result = aggregator.finish(); + + expect(result.buckets).toHaveLength(1); + expect(result.buckets[0]?.model).toBe("claude-fable-5"); + expect(result.buckets[0]?.records).toBe(2); + expect(result.buckets[0]?.costUsd).toBeCloseTo(0.00925, 9); + expect(result.buckets[0]?.unpricedRecords).toBe(0); + }); + it("separates providers and models into their own buckets", () => { const result = aggregate([ record(), @@ -263,3 +282,23 @@ describe("UsageAggregator", () => { expect(result.buckets).toHaveLength(3); }); }); + +describe("resolveModelAliases", () => { + it("follows chains to the final model and drops chains that enter a loop", () => { + expect( + resolveModelAliases({ + "preview[1m]": "preview", + preview: "example-model", + loop: "back", + back: "loop", + intoLoop: "loop", + self: "self", + }), + ).toEqual( + new Map([ + ["preview[1m]", "example-model"], + ["preview", "example-model"], + ]), + ); + }); +}); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 0652ca4b7dcf..f2923871a323 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -89,11 +89,35 @@ export interface AggregateOptions { readonly untilDay: string; readonly rates: RateTable; readonly priceOverrides?: RateTable; + /** From {@link resolveModelAliases}. Mapped records bucket and price as their target. */ + readonly modelAliases?: ReadonlyMap; readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; readonly untilTimeMs?: number; } +/** + * Resolves user model mappings to their final target, so `a -> b` and + * `b -> c` both land on `c`. A model whose chain enters a loop is left + * unmapped. + */ +export function resolveModelAliases( + aliases: Readonly>, +): ReadonlyMap { + const resolved = new Map(); + for (const model of Object.keys(aliases)) { + const seen = new Set([model]); + let target = aliases[model]!; + while (Object.hasOwn(aliases, target) && !seen.has(target)) { + seen.add(target); + target = aliases[target]!; + } + // Stopping on a mapped model means the chain entered a loop. + if (!Object.hasOwn(aliases, target)) resolved.set(model, target); + } + return resolved; +} + export interface AggregateResult { readonly buckets: readonly UsageBucket[]; /** Records dropped because an earlier record carried the same dedupe key. */ @@ -147,7 +171,8 @@ export class UsageAggregator { * can derive per-window facts (distinct sessions, for one) from the records * that landed rather than everything the mtime prefilter happened to admit. */ - add(record: UsageRecord, sourcePath?: string): boolean { + add(input: UsageRecord, sourcePath?: string): boolean { + const record = this.#mapModel(input); if (record.dedupeKey !== null) { if (this.#seen.has(record.dedupeKey)) { this.#duplicatesDropped += 1; @@ -217,6 +242,14 @@ export class UsageAggregator { return true; } + /** The target's own rate applies, so a provider-specific `rateModel` is dropped. */ + #mapModel(record: UsageRecord): UsageRecord { + const model = this.#options.modelAliases?.get(record.model); + if (model === undefined) return record; + const { rateModel: _rateModel, ...rest } = record; + return { ...rest, model }; + } + /** * Records arrive file by file in time order, so most land in the bucket the * previous record used. Checking that first skips building and hashing a diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e579bed63125..c0bb305b3ea5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -158,9 +158,9 @@ import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts"; import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts"; import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts"; import { - observeRpcEffect as instrumentRpcEffect, - observeRpcStream as instrumentRpcStream, - observeRpcStreamEffect as instrumentRpcStreamEffect, + observeRpcEffect, + observeRpcStream, + observeRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ServerPush from "./serverPush/ServerPush.ts"; @@ -209,7 +209,11 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; -import { requiredScopeForRpcMethod, requiredScopeForDeviceList } from "./auth/RpcAuthorization.ts"; +import { + requiredScopeForDeviceList, + rpcAuthorizationError, + rpcScopeAuthorizationLayer, +} from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; @@ -1314,25 +1318,15 @@ const makeWsRpcLayer = ( const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const relayClient = yield* RelayClient.RelayClient; - const authorizationError = (requiredScope: AuthEnvironmentScope) => - new EnvironmentAuthorizationError({ - message: `The authenticated token is missing required scope: ${requiredScope}.`, - requiredScope, - }); + // RpcScopeAuthorization checks each RPC's declared scope before its handler + // runs. This covers the one RPC whose scope depends on its input. const authorizeEffect = ( requiredScope: AuthEnvironmentScope, effect: Effect.Effect, ): Effect.Effect => currentSession.scopes.includes(requiredScope) ? effect - : Effect.fail(authorizationError(requiredScope)); - const authorizeStream = ( - requiredScope: AuthEnvironmentScope, - stream: Stream.Stream, - ): Stream.Stream => - currentSession.scopes.includes(requiredScope) - ? stream - : Stream.fail(authorizationError(requiredScope)); + : Effect.fail(rpcAuthorizationError(requiredScope)); const acpRegistryProject = Effect.fn("ws.acpRegistry.project")(function* ( projectId: ProjectId, @@ -1655,40 +1649,6 @@ const makeWsRpcLayer = ( yield* providerRegistry.refreshInstance(input.instanceId); return { disabled: true } as const; }); - const observeRpcEffect = ( - method: string, - effect: Effect.Effect, - traceAttributes?: Readonly>, - ) => - instrumentRpcEffect( - method, - authorizeEffect(requiredScopeForRpcMethod(method), effect), - traceAttributes, - ); - const observeRpcStream = ( - method: string, - stream: Stream.Stream, - traceAttributes?: Readonly>, - ) => - instrumentRpcStream( - method, - authorizeStream(requiredScopeForRpcMethod(method), stream), - traceAttributes, - ); - const observeRpcStreamEffect = ( - method: string, - effect: Effect.Effect< - Stream.Stream, - EffectError, - EffectContext - >, - traceAttributes?: Readonly>, - ) => - instrumentRpcStreamEffect( - method, - authorizeEffect(requiredScopeForRpcMethod(method), effect), - traceAttributes, - ); const loadAuthAccessSnapshot = () => Effect.all({ pairingLinks: serverAuth.listPairingLinks(), @@ -1842,11 +1802,16 @@ const makeWsRpcLayer = ( ORCHESTRATION_V2_WS_METHODS.dispatchCommand, startup .enqueueCommand( - ThreadMessageIntake.dispatchCommand( - ThreadManagementService.withCreationProvenance(command, { - createdBy: "user", - creationSource: "creationSource" in command ? command.creationSource : "web", - }), + // A retry also restarts the preparation work the launch owns. + (command.type === "prepared-run.retry" + ? threadLaunch.retryPreparation(command) + : ThreadMessageIntake.dispatchCommand( + ThreadManagementService.withCreationProvenance(command, { + createdBy: "user", + creationSource: + "creationSource" in command ? command.creationSource : "web", + }), + ) ).pipe(Effect.provide(intakeContext)), ) .pipe( @@ -3874,6 +3839,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket; yield* RpcServer.make(ServerWsRpcGroup, { disableTracing: true }).pipe( Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), + Effect.provide(rpcScopeAuthorizationLayer(session.scopes)), Effect.forkScoped, ); // @effect-diagnostics-next-line returnEffectInGen:off diff --git a/apps/web/package.json b/apps/web/package.json index 1c8ead2b6a53..6e6a22d917cf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -50,8 +50,10 @@ "jose": "catalog:", "jsonc-parser": "3.3.1", "jszip": "3.10.1", + "lucide": "^0.564.0", "lucide-react": "^0.564.0", "mermaid": "^11.17.2", + "morphicons": "^1.7.1", "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 4b4f8dd01ed2..1765a574e791 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -47,15 +47,16 @@ const BackgroundStudioSidebar = lazy(() => default: module.BackgroundStudioSidebar, })), ); -import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { useProjects } from "../state/entities"; import { + clampThreadSidebarWidth, resolveInitialThreadSidebarWidth, resolveThreadSidebarMaximumWidth, + resolveThreadSidebarMinimumWidth, THREAD_MAIN_CONTENT_MIN_WIDTH, - THREAD_SIDEBAR_MIN_WIDTH, THREAD_SIDEBAR_WIDTH_STORAGE_KEY, } from "./threadSidebarWidth"; import { @@ -249,7 +250,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { // and a clamped drag ends with an unchanged width, which skips the re-render // that would otherwise refresh a render-time snapshot. const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth); - const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth); + const [brandWidth, setBrandWidth] = useState(0); + const sidebarMinimumWidth = resolveThreadSidebarMinimumWidth(brandWidth); + const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth, sidebarMinimumWidth); const resetSidebarWidth = () => { try { removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY); @@ -265,7 +268,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { : false; }); const sidebarProviderStyle = { - "--sidebar-width": `${sidebarWidth}px`, + "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMinimumWidth, sidebarMaximumWidth)}px`, "--panel-animation-duration": `${panelAnimationDurationMs}ms`, ...(isMacosDesktop && !isWindowFullscreen ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } @@ -317,6 +320,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { defaultOpen style={sidebarProviderStyle} > + nextWidth <= currentWidth || wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index cfa62ad7fe92..d018802a590a 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -16,7 +16,8 @@ export { export interface EnvironmentOption { environmentId: EnvironmentId; - projectId: ProjectId; + /** Null when the machine's "No project" folder is not created yet. */ + projectId: ProjectId | null; label: string; isPrimary: boolean; machine: EnvironmentMachineKind; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 3092ef179246..85e2737dbdb7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -5,10 +5,8 @@ import { encodeComposerContextClipboardHtml, } from "@t3tools/shared/composerContextClipboard"; import { - CheckIcon, ChevronRightIcon, CodeIcon, - CopyIcon, FileSpreadsheetIcon, FileTextIcon, GlobeIcon, @@ -16,10 +14,8 @@ import { InfoIcon, LightbulbIcon, MailIcon, - Maximize2Icon, MessageSquareIcon, MessageSquareWarningIcon, - Minimize2Icon, OctagonAlertIcon, PlayIcon, PresentationIcon, @@ -29,6 +25,7 @@ import { WrapTextIcon, type LucideIcon, } from "lucide-react"; +import { Check, Copy, Maximize2, Minimize2 } from "lucide"; import type { AssetResource, EnvironmentId, @@ -123,6 +120,7 @@ import { import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; +import { MorphIcon } from "~/components/MorphIcon"; import { ContextChip } from "./ContextChip"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; import { ScrollArea } from "./ui/scroll-area"; @@ -849,7 +847,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { /> } > - {expanded ? : } + {expandLabel} @@ -869,7 +867,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { /> } > - {copied ? : } + {copyLabel} @@ -1063,7 +1061,7 @@ function MarkdownCodeBlock({ /> } > - {copied ? : } + {copyLabel} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ce3337fd3c6c..f74cd6913a61 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -23,6 +23,8 @@ import { rememberCheckoutIsRepo, } from "./ChatView.logic"; import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; +import { useScratchProject } from "../hooks/useScratchProject"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import { latestExecutedRun, @@ -265,7 +267,10 @@ import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; -import { pullRequestPanelContext } from "./pullRequest/pullRequestDetail.logic"; +import { + pullRequestPanelContext, + threadPullRequestPanelTarget, +} from "./pullRequest/pullRequestDetail.logic"; import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; @@ -293,7 +298,10 @@ import { import { cn, randomUUID } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; +import { + decodeProjectScriptKeybindingRule, + keybindingValueForCommand, +} from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { buildProjectScript, @@ -388,6 +396,7 @@ import { } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment } from "../state/threads"; +import { workspacePreparationRetryRunIds } from "@t3tools/client-runtime/state/turn-item-presentation"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; import { sourceControlEnvironment } from "../state/sourceControl"; @@ -1503,6 +1512,9 @@ function releaseChatTimelineAnchor(); + export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -1533,6 +1545,9 @@ export default function ChatView(props: ChatViewProps) { const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, }); + const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, { + reportFailure: false, + }); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); @@ -2643,26 +2658,56 @@ export default function ChatView(props: ChatViewProps) { }, [navigate, setEnvironmentEnabled], ); + const { scratchWorkspaceRootFor, openScratchProject } = useScratchProject(); + const activeProjectIsScratch = + activeProject !== null && + isScratchProject( + activeProject, + environmentById.get(activeProject.environmentId)?.serverConfig?.scratchWorkspaceRoot ?? null, + ); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; - const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); - const memberProjects = allProjects.filter( - (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, - ); - const seen = new Set(); const envs: EnvironmentOption[] = []; - for (const p of memberProjects) { - if (seen.has(p.environmentId)) continue; - seen.add(p.environmentId); - const isPrimary = p.environmentId === primaryEnvironmentId; - const environment = environmentById.get(p.environmentId) ?? null; + const pushEnvironment = (environmentId: EnvironmentId, projectId: ProjectId | null) => { + const environment = environmentById.get(environmentId) ?? null; envs.push({ - environmentId: p.environmentId, - projectId: p.id, - label: environment?.label ?? p.environmentId, - isPrimary, + environmentId, + projectId, + label: environment?.label ?? environmentId, + isPrimary: environmentId === primaryEnvironmentId, machine: resolveEnvironmentMachineKind(environment?.serverConfig ?? null), }); + }; + if (activeProjectIsScratch && draftId) { + // Each machine keeps its own "No project" folder at its own path, so they + // never group as one logical project. Offer every machine that has one. + for (const environment of environments) { + const scratchRoot = scratchWorkspaceRootFor(environment.environmentId); + // Keep the current machine visible so an offline source can still switch away. + if (scratchRoot === null && environment.environmentId !== activeProject.environmentId) + continue; + const scratchProject = + environment.environmentId === activeProject.environmentId + ? activeProject + : allProjects.find( + (p) => + p.environmentId === environment.environmentId && isScratchProject(p, scratchRoot), + ); + pushEnvironment(environment.environmentId, scratchProject?.id ?? null); + } + } else { + const logicalKey = deriveLogicalProjectKeyFromSettings( + activeProject, + projectGroupingSettings, + ); + const seen = new Set(); + for (const p of allProjects) { + if (seen.has(p.environmentId)) continue; + if (deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) !== logicalKey) + continue; + seen.add(p.environmentId); + pushEnvironment(p.environmentId, p.id); + } } // Sort: primary first, then alphabetical envs.sort((a, b) => { @@ -2670,8 +2715,21 @@ export default function ChatView(props: ChatViewProps) { return a.label.localeCompare(b.label); }); return envs; - }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); + }, [ + activeProject, + activeProjectIsScratch, + allProjects, + draftId, + environments, + projectGroupingSettings, + primaryEnvironmentId, + environmentById, + scratchWorkspaceRootFor, + ]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; + // Auto balance retargets to an existing project; a machine's "No project" + // folder may not exist until it is picked. + const canAutoBalanceEnvironments = hasMultipleEnvironments && !activeProjectIsScratch; const activeEnvironmentOption = logicalProjectEnvironments.find( (environment) => environment.environmentId === activeThread?.environmentId, @@ -2890,7 +2948,7 @@ export default function ChatView(props: ChatViewProps) { clientSettingsHydrated && draftId && !envLocked && - hasMultipleEnvironments && + canAutoBalanceEnvironments && loadBalancingSettings.loadBalancingEnabled && draftThread?.environmentSelection !== "manual" && (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) && @@ -3867,6 +3925,34 @@ export default function ChatView(props: ChatViewProps) { const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { reportFailure: false, }); + const retryWorkspacePreparation = useAtomCommand(threadEnvironment.retryWorkspacePreparation); + const retryableRunIdsKey = useMemo( + () => + [ + ...workspacePreparationRetryRunIds( + serverProjection?.runs ?? [], + serverProjection?.turnItems ?? [], + ), + ].join("\n"), + [serverProjection?.runs, serverProjection?.turnItems], + ); + // Keyed by content so the timeline context only changes when a retry appears or clears. + const retryableWorkspacePreparationRunIds = useMemo( + () => new Set(retryableRunIdsKey === "" ? [] : (retryableRunIdsKey.split("\n") as RunId[])), + [retryableRunIdsKey], + ); + const onRetryWorkspacePreparation = useCallback( + (runId: RunId) => { + // One retry per failed run: a second click lands after the run is preparing again. + if (!activeThreadRef || retryingWorkspacePreparationRunIds.has(runId)) return; + retryingWorkspacePreparationRunIds.add(runId); + void retryWorkspacePreparation({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId, runId }, + }).finally(() => retryingWorkspacePreparationRunIds.delete(runId)); + }, + [activeThreadRef, retryWorkspacePreparation], + ); const onCancelWorktreeSetup = useCallback(() => { if (!worktreeSetup || worktreeSetup.phase !== "running") return; void cancelWorktreeSetup({ @@ -4192,7 +4278,7 @@ export default function ChatView(props: ChatViewProps) { const target = logicalProjectEnvironments.find( (environment) => environment.environmentId === loadBalancing.environmentId, ); - if (!target) return; + if (!target?.projectId) return; setDraftThreadContext(draftId, { projectRef: scopeProjectRef(target.environmentId, target.projectId), environmentSelection: "auto", @@ -4245,23 +4331,91 @@ export default function ChatView(props: ChatViewProps) { : "Auto balance" : undefined; - // Handle environment change for draft threads. When the user picks a - // different environment we update the draft context to point at the physical - // project in that environment while keeping the same logical project. + const environmentChangeRef = useRef(null); + const [isEnvironmentChanging, setIsEnvironmentChanging] = useState(false); + useLayoutEffect(() => { + return () => { + environmentChangeRef.current = null; + setIsEnvironmentChanging(false); + }; + }, [draftId, activeProjectKey]); + const onEnvironmentChange = useCallback( (nextEnvironmentId: EnvironmentId) => { - if (envLocked || !draftId) return; + if (envLocked || !draftId || sendInFlightRef.current) return; + const originalDraft = getDraftSession(draftId); + if (!originalDraft || originalDraft.promotedTo) return; const target = logicalProjectEnvironments.find( (env) => env.environmentId === nextEnvironmentId, ); if (!target) return; - setDraftThreadContext(draftId, { - projectRef: scopeProjectRef(target.environmentId, target.projectId), - environmentSelection: "manual", - loadBalancedEnvironmentId: null, - }); + const request = Symbol(); + environmentChangeRef.current = request; + setIsEnvironmentChanging(false); + const retarget = (project: (typeof allProjects)[number]) => { + const currentDraft = getDraftSession(draftId); + if ( + environmentChangeRef.current !== request || + sendInFlightRef.current || + !currentDraft || + currentDraft.promotedTo || + currentDraft.environmentId !== originalDraft.environmentId || + currentDraft.projectId !== originalDraft.projectId + ) + return; + const projectRef = scopeProjectRef(target.environmentId, project.id); + if (activeProjectIsScratch) { + // Scratch projects are machine-local, so move their logical mapping too. + setLogicalProjectDraftThreadId( + deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), + projectRef, + draftId, + { environmentSelection: "manual", loadBalancedEnvironmentId: null }, + ); + } else { + setDraftThreadContext(draftId, { + projectRef, + environmentSelection: "manual", + loadBalancedEnvironmentId: null, + }); + } + }; + const finish = () => { + if (environmentChangeRef.current === request) { + environmentChangeRef.current = null; + setIsEnvironmentChanging(false); + } + }; + if (target.projectId !== null) { + const project = allProjects.find( + (project) => + project.environmentId === target.environmentId && project.id === target.projectId, + ); + if (project) retarget(project); + finish(); + return; + } + // Keep send disabled until the destination Scratch project is ready. + setIsEnvironmentChanging(true); + void openScratchProject(target.environmentId) + .then((project) => { + if (project) retarget(project); + }) + .finally(finish); }, - [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], + [ + activeProjectIsScratch, + allProjects, + draftId, + envLocked, + getDraftSession, + logicalProjectEnvironments, + openScratchProject, + projectGroupingSettings, + sendInFlightRef, + setDraftThreadContext, + setLogicalProjectDraftThreadId, + ], ); const activeTerminalGroup = @@ -4828,20 +4982,64 @@ export default function ChatView(props: ChatViewProps) { command: input.keybindingCommand, }); - if (isElectron && keybindingRule) { - return mapAtomCommandResult( - await upsertKeybinding({ - environmentId, - input: keybindingRule, - }), - () => undefined, - ); + if (!isElectron) return updateResult; + + const scriptId = input.keybindingCommand + ? projectScriptIdFromCommand(input.keybindingCommand) + : null; + if (!keybindingRule && !input.previousScripts.some((script) => script.id === scriptId)) { + return updateResult; } - return updateResult; + const retainedElsewhere = + !input.nextScripts.some((script) => script.id === scriptId) && + (settings.defaultProjectScripts.some((script) => script.id === scriptId) || + Object.entries(settings.projectSettingsOverrides).some( + ([projectId, entry]) => + projectId !== input.projectId && + entry.defaultProjectScripts?.some((script) => script.id === scriptId), + ) || + allProjects.some( + (other) => + other.environmentId === environmentId && + other.id !== input.projectId && + resolveProjectScripts(settings, other).some((script) => script.id === scriptId), + )); + if (!keybindingRule && retainedElsewhere) return updateResult; + + const previousRules = ( + environmentById.get(environmentId)?.serverConfig?.keybindings ?? [] + ).flatMap((binding) => { + if (binding.command !== input.keybindingCommand || binding.whenAst) return []; + const previous = decodeProjectScriptKeybindingRule({ + keybinding: keybindingValueForCommand([binding], input.keybindingCommand), + command: input.keybindingCommand, + }); + return previous ? [previous] : []; + }); + const previous = previousRules.at(-1); + for (const rule of keybindingRule ? previousRules.slice(0, -1) : previousRules) { + const result = await removeKeybinding({ environmentId, input: rule }); + if (result._tag === "Failure") return mapAtomCommandResult(result, () => undefined); + } + return keybindingRule + ? mapAtomCommandResult( + await upsertKeybinding({ + environmentId, + input: + previous && previous.key !== keybindingRule.key + ? { ...keybindingRule, replace: previous } + : keybindingRule, + }), + () => undefined, + ) + : updateResult; }, [ + allProjects, + environmentById, environmentId, - settings.projectSettingsOverrides, + removeKeybinding, + settings, supportsProjectSettingsOverrides, updateProjectScriptSettings, upsertKeybinding, @@ -6614,12 +6812,20 @@ export default function ChatView(props: ChatViewProps) { }, ); }, [activeThreadReferenceCopyTarget]); + const pullRequestPanelTarget = activeThread + ? threadPullRequestPanelTarget({ + projectId: activeThread.projectId, + pullRequests: visiblePullRequests, + linkedPullRequest: linkedThreadPullRequest, + branchPullRequest: activeThreadShell?.branchPullRequest ?? activeThread.branchPullRequest, + }) + : null; const addPullRequestSurface = useCallback(() => { - if (!supportsPullRequests || activeThreadRef === null || linkedThreadPullRequest === null) + if (!supportsPullRequests || activeThreadRef === null || pullRequestPanelTarget === null) return; - useRightPanelStore.getState().openPullRequest(activeThreadRef, linkedThreadPullRequest); - }, [activeThreadRef, linkedThreadPullRequest, supportsPullRequests]); - const pullRequestSurfaceAvailable = supportsPullRequests && linkedThreadPullRequest !== null; + useRightPanelStore.getState().openPullRequest(activeThreadRef, pullRequestPanelTarget); + }, [activeThreadRef, pullRequestPanelTarget, supportsPullRequests]); + const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true; @@ -8132,6 +8338,7 @@ export default function ChatView(props: ChatViewProps) { isRevertingCheckpoint || !clientSettingsHydrated || threadDetailLoading || + environmentChangeRef.current !== null || sendInFlightRef.current || feedbackUploadsInFlightRef.current.has(routeThreadKey) ) { @@ -8620,7 +8827,18 @@ export default function ChatView(props: ChatViewProps) { ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); - const shouldQueueBehindActiveRun = phase === "running" && dispatchMode === "queue"; + // Sending past the resume banner compacts first so the turn does not resend the stale + // history. The message queues behind the /compact run; steering into it is rejected, + // and a held queue would strand it. + const compactBeforeSend = + resumeCompactionBannerItem !== null && + !compactDisabled && + !hasHeldQueuedRuns && + multipleModelSelections === null && + messageTextForSend.toLowerCase() !== "/compact"; + const turnDispatchMode = compactBeforeSend ? "queue" : dispatchMode; + const shouldQueueBehindActiveRun = + compactBeforeSend || (phase === "running" && dispatchMode === "queue"); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, @@ -9186,6 +9404,22 @@ export default function ChatView(props: ChatViewProps) { failure = turnAttachmentsResult; } + if (failure === null && compactBeforeSend) { + const compactResult = await startThreadTurn({ + environmentId, + input: { + threadId: threadIdForSend, + message: { messageId: newMessageId(), role: "user", text: "/compact", attachments: [] }, + modelSelection: ctxSelectedModelSelection, + runtimeMode, + interactionMode: sendInteractionMode, + }, + }); + if (compactResult._tag === "Failure") { + failure = compactResult; + } + } + let backgroundDraftOpened = false; let turnStartSucceeded = false; if (failure === null && turnAttachmentsResult._tag === "Success") { @@ -9263,7 +9497,7 @@ export default function ChatView(props: ChatViewProps) { titleSeed: title, runtimeMode, interactionMode: sendInteractionMode, - dispatchMode, + dispatchMode: turnDispatchMode, ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }, @@ -10345,6 +10579,8 @@ export default function ChatView(props: ChatViewProps) { projectId: activeThread.projectId, pullRequests: visiblePullRequests, linkedPullRequest: linkedThreadPullRequest, + branchPullRequest: + activeThreadShell?.branchPullRequest ?? activeThread.branchPullRequest, }, renderedRightPanelSurface, )} @@ -10440,7 +10676,10 @@ export default function ChatView(props: ChatViewProps) { availableEnvironments: logicalProjectEnvironments, autoEnvironmentLabel, onAutoEnvironment: - draftId && !envLocked && hasMultipleEnvironments && loadBalancingSettings.loadBalancingEnabled + draftId && + !envLocked && + canAutoBalanceEnvironments && + loadBalancingSettings.loadBalancingEnabled ? onAutoEnvironment : undefined, onEnvironmentChange, @@ -10592,7 +10831,7 @@ export default function ChatView(props: ChatViewProps) { ref={threadPanelPopoverAnchorRef} data-chat-header className={cn( - "relative bg-transparent transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none", + "relative bg-transparent [[data-panel-animations=true]_&]:motion-safe:transition-[padding-left] [[data-panel-animations=true]_&]:motion-safe:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:motion-safe:ease-out", isElectron ? cn( "drag-region flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 sm:px-5", @@ -10693,6 +10932,9 @@ export default function ChatView(props: ChatViewProps) { activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt} worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup} onCancelWorktreeSetup={onCancelWorktreeSetup} + {...(paintOnlyDisplayedTimeline + ? {} + : { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation })} {...(draftId ? { onWorktreeSetupWorkLocally } : {})} {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})} isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree} @@ -10900,15 +11142,17 @@ export default function ChatView(props: ChatViewProps) { canResume={resumableRunId !== null || hasHeldQueuedRuns} isRevertingCheckpoint={isRevertingCheckpoint} sendDisabledReason={ - isRevertingCheckpoint - ? "Rewinding conversation" - : feedbackUploading - ? "Sending feedback" - : threadDetailLoading - ? "Messages loading" - : worktreeSetupBlocksSend - ? "Preparing worktree" - : projectCloneSendBlockReason + isEnvironmentChanging + ? "Preparing machine" + : isRevertingCheckpoint + ? "Rewinding conversation" + : feedbackUploading + ? "Sending feedback" + : threadDetailLoading + ? "Messages loading" + : worktreeSetupBlocksSend + ? "Preparing worktree" + : projectCloneSendBlockReason } isPreparingWorktree={isPreparingWorktree} queuedRunsControl={ @@ -11089,7 +11333,7 @@ export default function ChatView(props: ChatViewProps) { onAutoEnvironment={ draftId && !envLocked && - hasMultipleEnvironments && + canAutoBalanceEnvironments && loadBalancingSettings.loadBalancingEnabled ? onAutoEnvironment : undefined diff --git a/apps/web/src/components/DiffFilePathCopyButton.tsx b/apps/web/src/components/DiffFilePathCopyButton.tsx index 5e78949336cf..ab42f126189e 100644 --- a/apps/web/src/components/DiffFilePathCopyButton.tsx +++ b/apps/web/src/components/DiffFilePathCopyButton.tsx @@ -1,12 +1,14 @@ -import { CheckIcon, CopyIcon } from "lucide-react"; +import { Check, Copy } from "lucide"; import { useRef } from "react"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; +import { cn } from "../lib/utils"; import { ANCHORED_COPY_TOAST_TIMEOUT_MS, showAnchoredCopyErrorToast, showAnchoredCopySuccessToast, } from "./ui/anchoredCopyToast"; import { Button } from "./ui/button"; +import { MorphIcon } from "~/components/MorphIcon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; export function DiffFilePathCopyButton({ filePath }: { filePath: string }) { @@ -30,7 +32,10 @@ export function DiffFilePathCopyButton({ filePath }: { filePath: string }) { /> } > - {isCopied ? : } +

{isCopied ? "Copied" : "Copy path"}

diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 32f5bfabcbaf..50df89109e05 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -12,15 +12,13 @@ import { ArrowRightIcon, CheckIcon, ChevronDownIcon, - ChevronRightIcon, - ChevronsDownUpIcon, - ChevronsUpDownIcon, Columns2Icon, FolderTreeIcon, PilcrowIcon, Rows3Icon, TextWrapIcon, } from "lucide-react"; +import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide"; import * as Schema from "effect/Schema"; import * as DateTime from "effect/DateTime"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -58,6 +56,7 @@ import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/Ann import { DiffFileTree } from "./diffs/DiffFileTree"; import { diffFileTreeEntries } from "./diffs/diffFileTree.logic"; import { Button } from "./ui/button"; +import { MorphIcon } from "~/components/MorphIcon"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; import { Switch } from "./ui/switch"; import { @@ -873,11 +872,10 @@ export default function DiffPanel({ /> } > - {allDiffFilesCollapsed ? ( - - ) : ( - - )} + {allDiffFilesCollapsed ? "Expand all files" : "Collapse all files"} @@ -1133,15 +1131,10 @@ export default function DiffPanel({ /> } > - {collapsed ? ( - - ) : ( - - )} + {collapsed ? "Expand diff" : "Collapse diff"} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 9749f6ac7849..3e1356f988a1 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -2748,7 +2748,11 @@ function ProjectSortMenu({ } />} + render={ + } + /> + } > diff --git a/apps/web/src/components/MorphIcon.tsx b/apps/web/src/components/MorphIcon.tsx new file mode 100644 index 000000000000..053921dde42f --- /dev/null +++ b/apps/web/src/components/MorphIcon.tsx @@ -0,0 +1,6 @@ +import { MorphIcon as BaseMorphIcon, type MorphIconProps } from "morphicons/react"; + +/** Renders `lucide` icon data and morphs between shapes when `icon` changes. */ +export function MorphIcon({ reducedMotion = "user", ...props }: MorphIconProps) { + return ; +} diff --git a/apps/web/src/components/NightlyMobileBeta.tsx b/apps/web/src/components/NightlyMobileBeta.tsx new file mode 100644 index 000000000000..45e11c27e159 --- /dev/null +++ b/apps/web/src/components/NightlyMobileBeta.tsx @@ -0,0 +1,159 @@ +import { parseSemver } from "@t3tools/shared/semver"; +import { useNavigate } from "@tanstack/react-router"; +import * as Schema from "effect/Schema"; +import { SmartphoneIcon } from "lucide-react"; +import { useEffect } from "react"; + +import { APP_VERSION, HOSTED_APP_CHANNEL } from "../branding"; +import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; +import { getLocalStorageItem, setLocalStorageItem } from "../hooks/useLocalStorage"; +import { AndroidIcon, AppleIcon } from "./Icons"; +import { SettingsRow } from "./settings/settingsLayout"; +import { Button } from "./ui/button"; +import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover"; +import { QRCodeSvg } from "./ui/qr-code"; +import { toastManager } from "./ui/toast"; + +// Orchestrator V2 ships to Nightly first. The App Store and Google Play apps are +// still V1 and cannot connect to a V2 server, so Nightly users need the V2 beta +// app. Delete this file when the store apps move to V2. See #14871. + +/** True on Nightly desktop, `npx t3@nightly`, and the hosted Nightly app. */ +export const IS_NIGHTLY_BUILD = + parseSemver(APP_VERSION)?.prerelease[0] === "nightly" || HOSTED_APP_CHANNEL === "nightly"; + +const IOS_TESTFLIGHT_URL = "https://testflight.apple.com/join/XgaxaRtd"; +const ANDROID_BETA_GROUP_URL = "https://groups.google.com/g/t3-code-v2-beta"; +const ANDROID_PLAY_TESTING_URL = "https://play.google.com/apps/testing/com.t3tools.t3code"; + +const ROW_ID = "nightly-mobile-beta"; +const NOTICE_DISMISSED_STORAGE_KEY = "t3code:nightly-mobile-beta-notice-dismissed:v1"; + +// Guards against a second toast from a remount or a Strict Mode effect replay. +let noticeShown = false; + +function isNoticeDismissed(): boolean { + try { + return getLocalStorageItem(NOTICE_DISMISSED_STORAGE_KEY, Schema.Boolean) === true; + } catch { + return false; + } +} + +function dismissNotice() { + try { + setLocalStorageItem(NOTICE_DISMISSED_STORAGE_KEY, true, Schema.Boolean); + } catch { + // Storage is unavailable. The notice shows again on the next launch. + } +} + +/** + * One-time Nightly toast that points to the beta mobile app. Any close (the + * corner button, swipe, Dismiss, or the action) hides it for good on this client. + */ +export function NightlyMobileBetaNotice() { + const navigate = useNavigate(); + + useEffect(() => { + if (!IS_NIGHTLY_BUILD || noticeShown || isNoticeDismissed()) return; + noticeShown = true; + const toastId = toastManager.add({ + title: "Nightly needs the beta mobile app", + description: + "Nightly uses the new orchestrator. The App Store and Google Play versions of T3 Code cannot connect to it.", + timeout: 0, + onClose: dismissNotice, + actionProps: { + children: "Get the beta app", + onClick: () => { + toastManager.close(toastId); + void navigate({ to: "/settings/general", hash: ROW_ID }); + }, + }, + data: { + leadingIcon: , + actionLayout: "stacked-end", + secondaryActionProps: { + children: "Dismiss", + onClick: () => toastManager.close(toastId), + }, + secondaryActionVariant: "ghost", + }, + }); + }, [navigate]); + + return null; +} + +/** QR code plus a copy button for one beta link. */ +function BetaLinkQr({ url, label }: { url: string; label: string }) { + const { copyToClipboard, isCopied } = useCopyToClipboard(); + return ( +
+
+ +
+ +
+ ); +} + +/** Settings → General → About row with the beta app links. Render it only for Nightly. */ +export function NightlyMobileBetaRow() { + return ( + + + }> + + iPhone + + +
+
+ TestFlight beta +

Scan with your iPhone camera.

+
+ +
+
+
+ + }> + + Android + + +
+
+ Google Play beta +

+ Use the same Google account for both steps. Step 2 can take up to an hour to + work after you join the group. +

+
+
+
+

1. Join the group

+ +
+
+

2. Become a tester

+ +
+
+
+
+
+ + } + /> + ); +} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index d3c3d5c31ded..914028f7c8cd 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -23,9 +23,8 @@ import { Globe2, Plus, TerminalSquare, - Volume2, - VolumeOff, } from "lucide-react"; +import { Volume2, VolumeOff } from "lucide"; import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, @@ -44,6 +43,7 @@ import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { Button } from "~/components/ui/button"; +import { MorphIcon } from "~/components/MorphIcon"; import { AndroidIcon, AppleIcon } from "~/components/Icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { Kbd } from "~/components/ui/kbd"; @@ -1149,11 +1149,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { .catch(() => undefined); }} > - {audio === "muted" ? ( - - ) : ( - - )} + } /> diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 5b8b7688953a..6d3432f3ddb9 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -30,6 +30,7 @@ import { reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, resolveProjectStatusIndicator, + resolveSidebarSweepKeys, resolveSidebarStageBadgeLabel, resolveSidebarThreadSection, resolveSidebarRowAccessibility, @@ -2014,6 +2015,22 @@ describe("sortPinnedThreadsForSidebar", () => { }); }); +describe("resolveSidebarSweepKeys", () => { + const ordered = ["a", "b", "c", "d", "blocked"]; + const canSettle = (key: string) => key !== "blocked"; + + it("covers every row between the pressed row and the pointer, in either direction", () => { + expect(resolveSidebarSweepKeys(ordered, "b", "b", canSettle)).toEqual(["b"]); + expect(resolveSidebarSweepKeys(ordered, "b", "d", canSettle)).toEqual(["b", "c", "d"]); + expect(resolveSidebarSweepKeys(ordered, "d", "a", canSettle)).toEqual(["a", "b", "c", "d"]); + }); + + it("leaves out rows that cannot settle and rows that left the list", () => { + expect(resolveSidebarSweepKeys(ordered, "c", "blocked", canSettle)).toEqual(["c", "d"]); + expect(resolveSidebarSweepKeys(ordered, "gone", "a", canSettle)).toEqual([]); + }); +}); + describe("navigation after parking a thread", () => { it.each([ ["settle", "settled", null, "thread", true], diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 5a579bc503a4..3bd900ac4c67 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -274,6 +274,34 @@ export function resolveSidebarDropVerb( return "wake"; } +/** Eligible rows between the pressed action and the pointer, in sidebar order. */ +export function resolveSidebarSweepKeys( + orderedKeys: readonly string[], + originKey: string, + targetKey: string, + canApply: (key: string) => boolean, +): string[] { + const origin = orderedKeys.indexOf(originKey); + const target = orderedKeys.indexOf(targetKey); + if (origin === -1 || target === -1) return []; + return orderedKeys.slice(Math.min(origin, target), Math.max(origin, target) + 1).filter(canApply); +} + +/** The thread row at a pointer height, clamped to the rows visible in the + sidebar's scroll viewport. A gap between rows resolves to the row above + it. Rows carry their key in data-thread-item, which departing motion + clones drop. */ +export function sidebarThreadKeyAtY(list: HTMLElement, y: number): string | null { + const viewport = list.closest('[data-slot="scroll-area-viewport"]')?.getBoundingClientRect(); + const visibleY = viewport ? Math.min(Math.max(y, viewport.top), viewport.bottom - 1) : y; + let key: string | null = null; + for (const row of list.querySelectorAll("li[data-thread-item]")) { + if (key !== null && row.getBoundingClientRect().top > visibleY) break; + key = row.dataset.threadItem ?? null; + } + return key; +} + export function planSidebarThreadDrop(input: { readonly activeKey: string; readonly activeSection: SidebarSection; @@ -1008,21 +1036,6 @@ export function shouldShowSidebarV2Duration(status: SidebarThreadStatus): boolea return status === "working"; } -/** Working beta: threads busy with work that does not need the user fold into - the Working shelf: a running run, or one stopped with background work - that will wake it. Approvals, questions, plan prompts, and failures stay in the - inbox. */ -export function isSidebarThreadWorking(thread: ThreadStatusInput): boolean { - const status = resolveSidebarThreadStatus(thread); - if (status !== "working" && status !== "waiting") return false; - // A plan prompt outranks lingering background work: the user has to act on it. - return !( - thread.interactionMode === "plan" && - thread.hasActionableProposedPlan && - isLatestRunSettled(thread.latestRun, thread.runtime) - ); -} - /** First VALID timestamp wins: `a ?? b` falls through on null, but a present- yet-malformed string must also fall through to the next candidate rather than sink the row to the epoch. */ @@ -1038,6 +1051,11 @@ export function firstValidTimestampMs( } export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; +// The Working section beta folds and orders the inbox the same way on mobile. +export { + isThreadWorking as isSidebarThreadWorking, + sortInboxThreadsByReturn, +} from "@t3tools/client-runtime/state/thread-inbox"; // Pinned-reorder key math and the keyed sort live in client-runtime // (state/thread-sort) so web and mobile compute identical pinned orders. @@ -1119,36 +1137,6 @@ export function reduceSidebarProjectScopeMenuState( } } -/** Working beta: the inbox lists threads newest first by when each last came - back to the user, so a thread that leaves the Working shelf lands on top. - `observedReturnAt` adds returns the server does not stamp, such as an - approval request mid-turn or background work ending. */ -export function sortInboxThreadsByReturn< - T extends Pick< - SidebarThreadSummary, - "id" | "environmentId" | "createdAt" | "unsettledAt" | "latestRun" - >, ->(threads: readonly T[], observedReturnAt?: (thread: T) => number | undefined): T[] { - const timestamps = new Map( - threads.map((thread) => [ - thread, - Math.max( - toSortableTimestamp(thread.createdAt) ?? 0, - toSortableTimestamp(thread.unsettledAt ?? undefined) ?? 0, - toSortableTimestamp(thread.latestRun?.requestedAt ?? undefined) ?? 0, - toSortableTimestamp(thread.latestRun?.completedAt ?? undefined) ?? 0, - observedReturnAt?.(thread) ?? 0, - ), - ]), - ); - return [...threads].sort( - (left, right) => - timestamps.get(right)! - timestamps.get(left)! || - left.id.localeCompare(right.id) || - left.environmentId.localeCompare(right.environmentId), - ); -} - /** The timestamp a working thread's elapsed label counts from: when its current work started (request time until adoption). Background wakes do not reset it. Malformed timestamps fall through to the next candidate. */ diff --git a/apps/web/src/components/Sidebar.pointer.test.ts b/apps/web/src/components/Sidebar.pointer.test.ts index a70486673b64..3a54498c2304 100644 --- a/apps/web/src/components/Sidebar.pointer.test.ts +++ b/apps/web/src/components/Sidebar.pointer.test.ts @@ -80,6 +80,13 @@ describe("sidebar pointer lifecycle", () => { expect(drag.onFinish).toHaveBeenCalledOnce(); }); + it("moves to the pointer on the move that starts the drag", () => { + const drag = gesture(); + document.dispatchEvent(pointer("pointermove", { clientY: 90 })); + expect(drag.onStart).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 10 }); + expect(drag.onMove).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 90 }); + }); + const interruptions = { blur: () => window.dispatchEvent(new Event("blur")), hidden: () => { @@ -122,10 +129,11 @@ describe("sidebar pointer lifecycle", () => { const drag = gesture({ onMove, onDrop }); document.dispatchEvent(pointer("pointermove", { clientY: 20 })); document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 30 })); - expect(drag.onMove).toHaveBeenCalledExactlyOnceWith({ x: 30, y: 30 }); + expect(drag.onMove).toHaveBeenNthCalledWith(1, { x: 10, y: 20 }); + expect(drag.onMove).toHaveBeenNthCalledWith(2, { x: 30, y: 30 }); document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 30 })); // Outside the list the sort no longer sees the pointer. - expect(drag.onMove).toHaveBeenCalledOnce(); + expect(drag.onMove).toHaveBeenCalledTimes(2); document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 90, clientY: 30 })); expect(onDrop).toHaveBeenCalledExactlyOnceWith({ x: 90, y: 30 }); expect(drag.onCancel).toHaveBeenCalledOnce(); @@ -148,7 +156,8 @@ describe("sidebar pointer lifecycle", () => { document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 30 })); document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 100 })); document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 200 })); - expect(drag.onMove).toHaveBeenCalledExactlyOnceWith({ x: 30, y: 30 }); + expect(drag.onMove).toHaveBeenCalledTimes(2); + expect(drag.onMove).toHaveBeenLastCalledWith({ x: 30, y: 30 }); document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 60 })); expect(drag.onMove).toHaveBeenLastCalledWith({ x: 30, y: 60 }); diff --git a/apps/web/src/components/Sidebar.pointer.ts b/apps/web/src/components/Sidebar.pointer.ts index e2294b37ba0c..5ae83901ed1e 100644 --- a/apps/web/src/components/Sidebar.pointer.ts +++ b/apps/web/src/components/Sidebar.pointer.ts @@ -34,7 +34,9 @@ export class SidebarPointerSensor { private readonly document: Document; private readonly window: Window; - constructor(private readonly props: SensorProps) { + // The settle sweep constructs this sensor directly, outside dnd-kit, so it + // takes only the props the gesture reads. + constructor(private readonly props: Omit, "activeNode" | "context">) { this.pointer = props.event as PointerEvent; this.document = getOwnerDocument(this.pointer.target); this.window = getWindow(this.pointer.target); @@ -89,8 +91,9 @@ export class SidebarPointerSensor { this.document.addEventListener("selectionchange", this.clearSelection); this.clearSelection(); this.props.onStart(this.coordinates()); - return; } + // The move that starts a drag also moves it, so a release before the + // next pointermove still lands where the pointer is. if (this.phase === "dragging") { if (event.cancelable) event.preventDefault(); if (this.props.options.onMove?.(coordinates) === true) return; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index eb0499b0d93f..3a78102c9c75 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -7,7 +7,7 @@ import { endThreadContextDrag, moveThreadContextDrag as moveThreadContextDragGhost, } from "./chat/threadContextDrag"; -import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; +import { discardComposerDraft } from "../lib/discardComposerDraft"; import { requestCustomSnooze } from "./CustomSnoozeDialog"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; @@ -31,6 +31,7 @@ import { effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; +import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox"; import { resolveSettledThreadTimestamp, sortSettledThreads, @@ -91,6 +92,7 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; @@ -168,7 +170,7 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; -import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; +import { buildDraftActionMenuItems, buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { animateSidebarLayoutChanges, applySidebarThreadDrop, @@ -187,6 +189,7 @@ import { planSidebarThreadDrop, reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, + resolveSidebarSweepKeys, resolveSidebarDropTarget, resolveSidebarDropVerb, resolveSidebarThreadSection, @@ -201,6 +204,7 @@ import { resolveWorkingStartedAt, sidebarListItemId, sidebarMarkerId, + sidebarThreadKeyAtY, sortInboxThreadsByReturn, sortPinnedThreadsForSidebar, sortSidebarV2ProjectGroups, @@ -289,35 +293,7 @@ const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded"; // Working beta: when this client saw each thread leave the Working shelf. // Module scope keeps the inbox order across routes that unmount the sidebar. -let lastWorkingThreadKeys: ReadonlySet | null = null; -const observedInboxReturns = new Map(); - -/** Stamps threads that stopped working since the last call. The first call - only takes a baseline, so mounting never reshuffles the inbox. Pass null - to reset when the beta is off. */ -function observeInboxReturns(threads: readonly EnvironmentThreadShell[] | null): void { - if (threads === null) { - lastWorkingThreadKeys = null; - observedInboxReturns.clear(); - return; - } - const working = new Set(); - const present = new Set(); - for (const thread of threads) { - const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - present.add(key); - if (isSidebarThreadWorking(thread)) working.add(key); - } - // Drop deleted threads so the map stays bounded by the live thread list. - for (const key of observedInboxReturns.keys()) { - if (!present.has(key)) observedInboxReturns.delete(key); - } - const now = Date.now(); - for (const key of lastWorkingThreadKeys ?? []) { - if (present.has(key) && !working.has(key)) observedInboxReturns.set(key, now); - } - lastWorkingThreadKeys = working; -} +const inboxReturns = createInboxReturnTracker(); function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -733,6 +709,12 @@ function SidebarSectionPlaceholder(props: { ); } +// Pointer travel before a press on a row starts a drag, or a press on its +// action button starts a sweep. Shorter presses stay clicks. +const SIDEBAR_DRAG_DISTANCE = 6; + +type SidebarSweepAction = "settle" | "unsettle" | "unsnooze"; + // Zero-height markers reserve no label space at rest. During a drag the // sorting strategy opens 24px for a 16px label with 4px clearance on each side. const SIDEBAR_DRAG_LABEL_HEIGHT = 24; @@ -825,8 +807,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { isActive: boolean; onNavigate: (draftId: DraftId) => void; onDiscard: (draftId: DraftId) => void; + onContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; }) { - const { composer, draftId, onDiscard, onNavigate } = props; + const { composer, draftId, onContextMenu, onDiscard, onNavigate } = props; const promptPreview = replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label) .trim() @@ -856,12 +839,23 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { // preventDefault here would swallow Space's synthesized click and // navigate instead of discarding. if ((event.target as HTMLElement).closest("button")) return; - if (event.key === "Enter" || event.key === " ") { + if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { + event.preventDefault(); + const rect = event.currentTarget.getBoundingClientRect(); + onContextMenu(draftId, { x: rect.left, y: rect.bottom }); + } else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onNavigate(draftId); } }, - [draftId, onNavigate], + [draftId, onContextMenu, onNavigate], + ); + const handleContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + onContextMenu(draftId, { x: event.clientX, y: event.clientY }); + }, + [draftId, onContextMenu], ); const handleDiscard = useCallback( (event: ReactMouseEvent) => { @@ -884,6 +878,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { props.isActive ? "bg-sidebar-row-active" : draftSurfaceClassName, )} onClick={handleActivate} + onContextMenu={handleContextMenu} onKeyDown={handleKeyDown} > {preview} @@ -939,10 +934,10 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { scopedProjectKeys: ReadonlySet | null; routeDraftId: string | null; onNavigateToDraft: (draftId: DraftId) => void; + onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; }) { const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); - const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread); // The open draft's row is FROZEN at the moment the draft became the route: // it stays visible (like a thread row) but never repaints while the user // types. A draft that was never navigated away from has no snapshot to @@ -1006,16 +1001,6 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { props.routeDraftId, props.scopedProjectKeys, ]); - const handleDiscard = useCallback( - (draftId: DraftId) => { - // The /draft/$draftId route redirects home on its own when the draft - // it renders disappears, so discarding the open draft needs no - // special-casing here. - releaseComposerDraftUploads(draftId); - clearDraftThread(draftId); - }, - [clearDraftThread], - ); if (drafts.length === 0) { return null; } @@ -1032,7 +1017,11 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { projectDisplayName={props.projectDisplayNameByKey.get(projectKey) ?? null} isActive={draftId === props.routeDraftId} onNavigate={props.onNavigateToDraft} - onDiscard={handleDiscard} + // The /draft/$draftId route redirects home on its own when the + // draft it renders disappears, so discarding the open draft needs + // no special-casing here. + onDiscard={discardComposerDraft} + onContextMenu={props.onDraftContextMenu} /> ); })} @@ -1084,9 +1073,8 @@ const dropVerbBadge: Record = { const SidebarThreadRow = memo(function SidebarThreadRow(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; - // Slim rows are either settled (action: un-settle) or merely quiet - // (seen Ready threads — action: settle). - variantAction: "settle" | "unsettle" | "unsnooze"; + // Settled rows un-settle, snoozed rows wake, and cards settle. + variantAction: SidebarSweepAction; // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; @@ -1110,6 +1098,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // the pinned section. Any other position shows the verb badge instead, and // the badge carries its own icon. dragOverPinned: boolean; + // The action this row will take when the sweep is released. + sweepAction: SidebarSweepAction | null; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until @@ -1135,6 +1125,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { renamingTitle: string; onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; onSettle: (threadRef: ScopedThreadRef) => void; + onActionSweepStart: ( + threadRef: ScopedThreadRef, + action: SidebarSweepAction, + event: PointerEvent, + ) => void; onUnsettle: (threadRef: ScopedThreadRef) => void; onSnooze: (threadRef: ScopedThreadRef, preset: Pick) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; @@ -1158,6 +1153,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onFileDropThreads, onRenameTitleChange, onSettle, + onActionSweepStart, onSnooze, onStartRename, onThreadActivate, @@ -1191,15 +1187,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // Unsent composer text on this thread. The open thread shows its own // composer, so the marker only decorates rows you have navigated away from. const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isActive; - const clearComposerContent = useComposerDraftStore((store) => store.clearComposerContent); const handleDiscardDraftClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); - releaseComposerDraftUploads(threadRef); - clearComposerContent(threadRef); + discardComposerDraft(threadRef); }, - [clearComposerContent, threadRef], + [threadRef], ); const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; @@ -1455,6 +1449,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [onSettle, threadRef], ); + const handleActionPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (!event.isPrimary || event.button !== 0) return; + // Action buttons sweep their section rather than picking up the row. + event.stopPropagation(); + onActionSweepStart(threadRef, variantAction, event.nativeEvent); + }, + [onActionSweepStart, threadRef, variantAction], + ); const handleUnsettleClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); @@ -1532,7 +1535,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70", props.isActive ? "bg-sidebar-row-active text-sidebar-foreground" - : isSelected + : isSelected || props.sweepAction !== null ? "bg-sidebar-row-selected text-sidebar-foreground" : hasUnsentDraft ? cn(draftSurfaceClassName, "text-sidebar-foreground") @@ -1572,13 +1575,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ...sortable.listeners, } : {}; + // Sweeps reuse the corresponding row-drop action badge. + const destinationVerb = sortable?.isDragging + ? props.dropVerb + : props.sweepAction === "unsnooze" + ? "wake" + : props.sweepAction; const dragDestination = - sortable?.isDragging && props.dropVerb !== null ? ( + destinationVerb !== null ? ( - {dropVerbBadge[props.dropVerb]} + {dropVerbBadge[destinationVerb]} ) : null; @@ -1723,7 +1732,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { if (variant === "slim") { return (
  • + )} + {props.sweepAction !== null ? dragDestination : null} {props.jumpLabel ? : null} {detailsTooltip} @@ -1879,7 +1897,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { return (
  • + {/* Read-only status labels yield to the hover actions. Woke is itself an action, so it stays pointer-enabled and visible while the other controls appear beside it. */} @@ -2046,6 +2069,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} + onPointerDown={handleActionPointerDown} className="-mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" /> } @@ -2060,6 +2084,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null} )} + {/* A sweep hides the slot rather than unmounting it, so the + pressed action button stays connected and a cancelled sweep's + release click still fires and is consumed. */} + {props.sweepAction !== null ? dragDestination : null}
    {title} @@ -2669,7 +2697,7 @@ export default function Sidebar() { // Subagent child threads live in the parent's Agents surface, not the // sidebar roster (v2 models them as real threads with lineage). const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); - observeInboxReturns(workingShelfEnabled ? threads : null); + inboxReturns.observe(workingShelfEnabled ? threads : null); const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; const working: EnvironmentThreadShell[] = []; @@ -2736,11 +2764,7 @@ export default function Sidebar() { // web and mobile from the same data. const sortedPinned = sortPinnedThreadsForSidebar(pinned); const sortedActive = workingShelfEnabled - ? sortInboxThreadsByReturn(active, (thread) => - observedInboxReturns.get( - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - ) + ? sortInboxThreadsByReturn(active, inboxReturns.returnedAt) : sortThreadsForSidebar(active); return { pinnedThreads: @@ -3278,6 +3302,22 @@ export default function Sidebar() { }, [planForwardNavigation, settleThread], ); + // Post-settle navigation must skip threads settling in this same batch — + // they are all leaving the card block together. Rows that are already + // explicitly settled are skipped: nothing to do on a valid mixed selection. + // Pinned rows ARE included: the decider clears the pin as part of settling, + // so they park like the rest. + const settleThreads = useCallback( + (threadKeys: readonly string[]) => { + const coSettlingKeys = new Set(threadKeys); + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread || thread.settledOverride === "settled") continue; + attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); + } + }, + [attemptSettle], + ); const attemptUnsettle = useCallback( (threadRef: ScopedThreadRef) => { void (async () => { @@ -3404,7 +3444,7 @@ export default function Sidebar() { ); const dndSensors = useSensors( useSensor(SidebarPointerSensor, { - distance: 6, + distance: SIDEBAR_DRAG_DISTANCE, onAttach: attachDragSensor, onFinish: finishThreadDrag, onMove: moveThreadContextDrag, @@ -3425,6 +3465,77 @@ export default function Sidebar() { add(settledThreads, "settled"); return map; }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads, workingThreads]); + const sectionByThreadKeyRef = useRef(sectionByThreadKey); + sectionByThreadKeyRef.current = sectionByThreadKey; + // Drag a row action to apply it to the armed rows in the same section. + // Staying in one section keeps an overshoot from changing neighboring + // sections, whose boundaries may be hidden outside a row drag. The sweep + // runs on the row drag's sensor and ref, so unmounting the list cancels it. + const [actionSweep, setActionSweep] = useState<{ + action: SidebarSweepAction; + keys: ReadonlySet; + } | null>(null); + const startActionSweep = useCallback( + (threadRef: ScopedThreadRef, action: SidebarSweepAction, event: PointerEvent) => { + const originKey = scopedThreadKey(threadRef); + const originSection = sectionByThreadKeyRef.current.get(originKey); + const canApply = (key: string) => { + const thread = threadByKeyRef.current.get(key); + const capabilities = + thread && serverConfigs.get(thread.environmentId)?.environment.capabilities; + return ( + thread !== undefined && + sectionByThreadKeyRef.current.get(key) === originSection && + (action === "unsnooze" + ? capabilities?.threadSnooze === true + : capabilities?.threadSettlement === true) + ); + }; + let targetKey: string | null = null; + let sweptKeys: string[] = []; + const sweepTo = (key: string | null) => { + if (key === null || key === targetKey) return; + targetKey = key; + sweptKeys = resolveSidebarSweepKeys(orderedThreadKeysRef.current, originKey, key, canApply); + setActionSweep({ action, keys: new Set(sweptKeys) }); + }; + dragSensorRef.current = new SidebarPointerSensor({ + active: originKey, + event, + options: { + distance: SIDEBAR_DRAG_DISTANCE, + onAttach: () => {}, + onFinish: () => { + dragSensorRef.current = null; + setActionSweep(null); + }, + }, + onPending: () => {}, + onStart: () => sweepTo(originKey), + onMove: ({ y }) => + sweepTo(threadListRef.current && sidebarThreadKeyAtY(threadListRef.current, y)), + // Also runs after a press that never moved. Nothing is swept then, + // and the button's own click applies its action. Rows that changed + // section mid-gesture, say pinned from another device, are skipped. + onEnd: () => { + const keys = sweptKeys.filter(canApply); + if (action === "settle") { + settleThreads(keys); + return; + } + for (const key of keys) { + const ref = parseScopedThreadKey(key); + if (ref === null) continue; + if (action === "unsettle") attemptUnsettle(ref); + else attemptUnsnooze(ref); + } + }, + onCancel: () => {}, + onAbort: () => {}, + }); + }, + [attemptUnsettle, attemptUnsnooze, serverConfigs, settleThreads], + ); const pinnedKeys = useMemo( () => pinnedThreads.map((thread) => @@ -3701,7 +3812,7 @@ export default function Sidebar() { ...activeThreads.filter((candidate) => key(candidate) !== dragState.activeKey), applySidebarThreadDrop(thread, "active", dragState.occurredAt), ], - (candidate) => observedInboxReturns.get(key(candidate)), + inboxReturns.returnedAt, ).map(key); }, [activeThreads, dragState, threadByKey, workingShelfEnabled]); const sidebarSortingStrategy = useMemo( @@ -4181,17 +4292,7 @@ export default function Sidebar() { return; } if (clicked.value === "settle") { - // Post-settle navigation must skip threads settling in this same - // batch — they are all leaving the card block together. Rows that - // are already explicitly settled are skipped: nothing to do on a - // valid mixed selection. Pinned rows ARE included: the decider - // clears the pin as part of settling, so they park like the rest. - const coSettlingKeys = new Set(threadKeys); - for (const threadKey of threadKeys) { - const thread = threadByKeyRef.current.get(threadKey); - if (!thread || thread.settledOverride === "settled") continue; - attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); - } + settleThreads(threadKeys); clearSelection(); return; } @@ -4244,7 +4345,6 @@ export default function Sidebar() { ); }, [ - attemptSettle, attemptUnpin, clearSelection, confirmThreadDelete, @@ -4253,11 +4353,61 @@ export default function Sidebar() { performSnooze, removeFromSelection, serverConfigs, + settleThreads, updateThreadMetadata, timestampFormat, ], ); + const handleDraftContextMenu = useCallback( + (draftId: DraftId, position: { x: number; y: number }) => { + void (async () => { + const api = readLocalApi(); + const session = useComposerDraftStore.getState().getDraftSession(draftId); + if (!api || !session || session.promotedTo) return; + const projectGroup = projectGroupsRef.current.find((group) => + group.memberProjectRefs.some( + (ref) => + ref.environmentId === session.environmentId && ref.projectId === session.projectId, + ), + ); + const workspacePath = + session.worktreePath ?? + projectByKey.get(`${session.environmentId}:${session.projectId}`)?.workspaceRoot; + const clicked = await settlePromise(() => + api.contextMenu.show( + buildDraftActionMenuItems({ + hasPath: Boolean(workspacePath), + hasBranch: Boolean(session.branch), + hasProject: projectGroup != null, + }), + position, + ), + ); + if (clicked._tag === "Failure") return; + switch (clicked.value) { + case "project-settings": + if (projectGroup) openProjectSettings(projectGroup); + return; + case "copy-path": + if (workspacePath) copyPathToClipboard(workspacePath, { path: workspacePath }); + return; + case "copy-branch": + if (session.branch) copyBranchToClipboard(session.branch, { branch: session.branch }); + return; + case "discard": { + // The menu can stay open while the draft sends; discarding a + // promoting draft would strand the send. + const current = useComposerDraftStore.getState().getDraftSession(draftId); + if (current && !current.promotedTo) discardComposerDraft(draftId); + return; + } + } + })(); + }, + [copyBranchToClipboard, copyPathToClipboard, openProjectSettings, projectByKey], + ); + const handleThreadContextMenu = useCallback( (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { void (async () => { @@ -4930,6 +5080,10 @@ export default function Sidebar() { className={cn( "relative flex flex-col gap-px", sidebarListItems.length > 0 && "flex-1", + // An action sweep owns the pointer: rows it passes over + // neither show hover actions nor open tooltips, even + // controls that opt back in, like the Woke pill. + actionSweep !== null && "**:pointer-events-none", )} > {(() => { @@ -4986,6 +5140,9 @@ export default function Sidebar() { dragOverPinned={ dragState?.activeKey === threadKey && dragTargetSection === "pinned" } + sweepAction={ + actionSweep?.keys.has(threadKey) ? actionSweep.action : null + } snoozeWakeLabelText={ section === "snoozed" && thread.snoozedUntil != null ? snoozeWakeLabel(thread.snoozedUntil, { @@ -5034,6 +5191,7 @@ export default function Sidebar() { renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""} onContextMenu={handleThreadContextMenu} onSettle={attemptSettle} + onActionSweepStart={startActionSweep} onUnsettle={attemptUnsettle} onSnooze={attemptSnooze} onUnsnooze={attemptUnsnooze} @@ -5079,6 +5237,7 @@ export default function Sidebar() { scopedProjectKeys={scopedProjectKeys} routeDraftId={routeDraftIdForRows} onNavigateToDraft={navigateToDraft} + onDraftContextMenu={handleDraftContextMenu} />, ]; for (const item of sidebarListItems) { diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 08cf6d435ca3..946834bb297e 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -827,6 +827,7 @@ export function ThreadStatusLabel({ @@ -848,6 +849,7 @@ export function ThreadStatusLabel({ diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index dba2125ff5e4..5821f1995a58 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -6,20 +6,15 @@ import { summarizeTurnDiffStats, type TurnDiffTreeNode, } from "../../lib/turnDiffTree"; -import { - ChevronsDownUpIcon, - ChevronsUpDownIcon, - ChevronRightIcon, - FileDiffIcon, - FolderIcon, - FolderClosedIcon, -} from "lucide-react"; +import { ChevronRightIcon, FileDiffIcon } from "lucide-react"; +import { ChevronsDownUp, ChevronsUpDown, Folder, FolderClosed } from "lucide"; import { cn } from "~/lib/utils"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { MiddleTruncate } from "../ui/middle-truncate"; +import { MorphIcon } from "~/components/MorphIcon"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; @@ -86,11 +81,10 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { /> } > - {allDirectoriesExpanded ? ( - - ) : ( - - )} + {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} @@ -195,11 +189,10 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { isExpanded && "rotate-90", )} /> - {isExpanded ? ( - - ) : ( - - )} + {node.name} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 76156ffda49c..81bce817fe4a 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -1,12 +1,7 @@ import { memo, type MouseEventHandler, type PointerEventHandler } from "react"; -import { - CheckIcon, - ChevronDownIcon, - ChevronLeftIcon, - CornerUpRightIcon, - ListPlusIcon, - PlayIcon, -} from "lucide-react"; +import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, PlayIcon } from "lucide-react"; +import { CornerUpRight, ListPlus } from "lucide"; +import { MorphIcon } from "~/components/MorphIcon"; import { useEnvironmentIdentificationMode } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { useShortcutModifierState } from "../../shortcutModifierState"; @@ -315,10 +310,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ {contentsLabel} diff --git a/apps/web/src/components/chat/MessageCopyButton.tsx b/apps/web/src/components/chat/MessageCopyButton.tsx index 3a647857bae9..ebf12987a988 100644 --- a/apps/web/src/components/chat/MessageCopyButton.tsx +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -1,6 +1,8 @@ import { memo, useRef } from "react"; -import { CopyIcon, CheckIcon } from "lucide-react"; +import { Check, Copy } from "lucide"; import { Button } from "../ui/button"; +import { MorphIcon } from "~/components/MorphIcon"; +import { cn } from "~/lib/utils"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { ANCHORED_COPY_TOAST_TIMEOUT_MS, @@ -47,7 +49,10 @@ export const MessageCopyButton = memo(function MessageCopyButton({ /> } > - {isCopied ? : } +

    Copy message

    diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5bc027e78fc1..3706e311beca 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -18,6 +18,7 @@ import { } from "./timelineMinimapItems"; import { COMPOSER_CONTEXT_KINDS, + ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, type AssistantCitation, type EnvironmentId, type MessageId, @@ -139,13 +140,16 @@ import { WrenchIcon, XIcon, ZapIcon, + RotateCcwIcon, } from "lucide-react"; +import { ChevronDown, ChevronRight } from "lucide"; import type { ComposerContextId, ComposerContextRecord, KnownComposerContextRecord, } from "@t3tools/contracts"; import { Button, InlineButton } from "../ui/button"; +import { MorphIcon } from "~/components/MorphIcon"; import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; @@ -264,6 +268,7 @@ import { isV2LifecycleItem, SubagentAvatar, SubagentElapsed, + SubagentNotificationLink, V2LifecycleRow, type HandoffTimelineRun, } from "./V2LifecycleRow"; @@ -323,6 +328,8 @@ interface TimelineRowSharedState { onToggleWorkGroup: (groupId: string, anchorKey: string) => void; onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void; onCancelWorktreeSetup: (() => void) | null; + retryableWorkspacePreparationRunIds: ReadonlySet; + onRetryWorkspacePreparation: ((runId: RunId) => void) | null; onWorktreeSetupWorkLocally: (() => void) | null; onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null; workGroupViewState: WorkGroupViewState; @@ -381,6 +388,7 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = { }, } as const satisfies MaintainScrollAtEndOptions; const EMPTY_TIMELINE_RUNS: ReadonlyArray = []; +const EMPTY_RUN_IDS: ReadonlySet = new Set(); // Streamed text lands a paragraph at a time. A smooth scroll to the end // turns each landing into a short glide instead of a jump. Thread switches // and layout settles keep the instant variant so nothing visibly travels. @@ -414,6 +422,9 @@ interface MessagesTimelineProps { activeTurnStartedAt?: string | null; worktreeSetup?: WorktreeSetupSnapshot | null; onCancelWorktreeSetup?: () => void; + /** Runs whose failed workspace preparation can be retried, keyed by run id. */ + retryableWorkspacePreparationRunIds?: ReadonlySet; + onRetryWorkspacePreparation?: (runId: RunId) => void; onWorktreeSetupWorkLocally?: () => void; onOpenWorktreeSetupTerminal?: (terminalId: string) => void; isPreparingWorktree?: boolean; @@ -497,6 +508,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ activeTurnStartedAt = null, worktreeSetup = null, onCancelWorktreeSetup, + retryableWorkspacePreparationRunIds = EMPTY_RUN_IDS, + onRetryWorkspacePreparation, onWorktreeSetupWorkLocally, onOpenWorktreeSetupTerminal, isPreparingWorktree = false, @@ -1162,6 +1175,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onToggleWorkGroup, onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure, onCancelWorktreeSetup: onCancelWorktreeSetup ?? null, + retryableWorkspacePreparationRunIds, + onRetryWorkspacePreparation: onRetryWorkspacePreparation ?? null, onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null, onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null, workGroupViewState, @@ -1195,6 +1210,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onToggleWorkGroup, suspendEndScrollMaintenanceForDisclosure, onCancelWorktreeSetup, + retryableWorkspacePreparationRunIds, + onRetryWorkspacePreparation, onWorktreeSetupWorkLocally, onOpenWorktreeSetupTerminal, workGroupViewState, @@ -2429,7 +2446,6 @@ function TimelineRowTimestamp({ function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon; return (
    {row.label} - + }) { const ctx = use(TimelineRowCtx); - const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon; return ( @@ -4949,15 +4967,40 @@ function ReasoningTraceContent({ entries }: { entries: ReadonlyArray void) | undefined; -}) { +}; + +const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: WorkEntryRowProps) { + const ctx = use(TimelineRowCtx); + const item = props.workEntry.projectedItem?.item; + const childThreadId = + item?.type === "notification" ? notificationChildThreadId(item.source) : undefined; + if (item?.type !== "notification" || childThreadId === undefined) { + return ; + } + return ( + } + /> + ); +}); + +function WorkEntryLogRow(props: WorkEntryRowProps) { const { workEntry, workspaceRoot, displayLabel } = props; const ctx = use(TimelineRowCtx); const { threadRef, onImageExpand, timestampFormat } = ctx; + const { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation } = ctx; const createdThread = workEntry.projectedItem?.item.type === "thread_created" ? workEntry.projectedItem.item @@ -4989,6 +5032,12 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { const label = warning ? `Usage limit reached.${resetTime ? ` Retry after ${resetTime}.` : ""}` : workEntry.label; + const retryRunId = + failureItem.runId !== null && + retryableWorkspacePreparationRunIds.has(failureItem.runId) && + failureItem.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE + ? failureItem.runId + : null; return ( ) : null} + {retryRunId !== null && onRetryWorkspacePreparation ? ( +
    + +
    + ) : null}
    ); } @@ -5288,7 +5350,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ) : null} ); -}); +} function QuestionAnswerHistory({ answer, diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 70181947bc4a..53a2e9c45bac 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -144,6 +144,7 @@ export function adjacentModelPickerProvider(input: { } const EMPTY_MODEL_JUMP_LABELS = new Map(); +const MODEL_LIST_ESTIMATED_ITEM_SIZE = 52; function ModelListSeparator() { return
    ; @@ -197,6 +198,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const [showBottomScrollFade, setShowBottomScrollFade] = useState(false); const searchInputRef = useRef(null); const modelListRef = useRef(null); + const pickerContentRef = useRef(null); const highlightedModelKeyRef = useRef(null); const favorites = useClientSettings((s) => s.favorites ?? []); const activeEntry = props.instanceEntries.find( @@ -699,6 +701,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ), [visibleModels], ); + const [modelListContentSize, setModelListContentSize] = useState( + () => filteredItemKeys.length * MODEL_LIST_ESTIMATED_ITEM_SIZE, + ); + const [searchHeight, setSearchHeight] = useState(0); + useLayoutEffect( + () => modelListRef.current?.getState().listen("totalSize", setModelListContentSize), + [], + ); + // Fit the list to its rows plus the combobox list `py-1` and LegendList `py-1.5`. + const modelListHeight = + filteredItemKeys.length === 0 ? 0 : `calc(${modelListContentSize}px + var(--spacing) * 5)`; const updateModelListScrollFades = useCallback(() => { const scrollElement = modelListRef.current?.getScrollableNode(); if (!(scrollElement instanceof HTMLElement)) { @@ -815,7 +828,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return (
    {/* Sidebar */} @@ -890,7 +906,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ref={searchInputRef} placeholder="Search models..." value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={(e) => { + if (!isSearching) setSearchHeight(pickerContentRef.current?.offsetHeight ?? 0); + setSearchQuery(e.target.value); + }} onKeyDown={(e) => { if ( showSidebar && @@ -944,7 +963,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { /> {/* Model list */} -
    +
    ref={modelListRef} @@ -1013,7 +1035,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { /> ); }} - estimatedItemSize={52} + estimatedItemSize={MODEL_LIST_ESTIMATED_ITEM_SIZE} drawDistance={480} recycleItems contentContainerClassName="pl-2 pr-px" diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index 41cd19fb4013..ff6f00e28272 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -1,10 +1,6 @@ -import { - Maximize2Icon, - Minimize2Icon, - PanelBottomIcon, - PanelRightIcon, - SquareMenuIcon, -} from "lucide-react"; +import { PanelBottomIcon, PanelRightIcon, SquareMenuIcon } from "lucide-react"; +import { Maximize2, Minimize2 } from "lucide"; +import { MorphIcon } from "~/components/MorphIcon"; import { memo, type ReactElement } from "react"; import type { ThreadPanelPresentation } from "../../rightPanelLayout"; @@ -163,11 +159,7 @@ export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl variant="ghost" size="sm" > - {maximized ? ( - - ) : ( - - )} + } /> diff --git a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx index a18110444e1f..fdc2f0faf5b0 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx @@ -5,7 +5,7 @@ import { threadPullRequestKeyOf, visibleThreadPullRequests, } from "@t3tools/shared/threadPullRequests"; -import { MinusIcon, PlusIcon } from "lucide-react"; +import { Minus, Plus } from "lucide"; import { useState, type ComponentProps, type MouseEvent as ReactMouseEvent } from "react"; import { findProjectOnChangeRequestHost, parseChangeRequestUrl } from "~/lib/openPullRequestLink"; @@ -13,6 +13,7 @@ import { findProjectOnChangeRequestHost, parseChangeRequestUrl } from "~/lib/ope import { useProjects } from "~/state/entities"; import { pullRequestListLines } from "../pullRequest/pullRequestListLines"; +import { MorphIcon } from "~/components/MorphIcon"; import { linkedPullRequestSnapshotStatus, prStatusIndicator } from "../ThreadStatusIndicators"; import { ThreadDetailsPrRow } from "./ThreadDetailsPrRow"; @@ -96,11 +97,7 @@ export function ThreadDetailsPrRows({ tone="muted" className="w-full active:scale-100" > - {expanded ? ( - - ) : ( - - )} + {expanded ? "Show less" : `Show ${rest.length} more`} diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 418f917a3f61..b3e90485b919 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -5,7 +5,10 @@ import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible import { SubagentTooltipContent } from "./SubagentTooltipContent"; import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { projectedSubagentsToRuntime } from "@t3tools/client-runtime/state/subagentRuntime"; +import { + projectedSubagentsToRuntime, + type RuntimeSubagent, +} from "@t3tools/client-runtime/state/subagentRuntime"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; import { deriveThreadRelationshipGraph, @@ -23,6 +26,7 @@ import { } from "@t3tools/client-runtime/state/thread-workflows"; import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; import { groupBy } from "effect/Array"; +import * as DateTime from "effect/DateTime"; import { useNavigate } from "@tanstack/react-router"; import { ArrowRightIcon, @@ -162,6 +166,30 @@ function relationshipThreadTitle(input: { return formatSubagentDisplayTitle(input.title); } +/** + * A delegated task settles with its first run, but the parent can keep sending + * the child follow-ups. While the child thread has a live run, the row's timer + * and hover card follow that run instead of the settled task. + */ +function liveSubagent( + agent: Agent | undefined, + childThread: OrchestrationV2ThreadShell | null | undefined, +): Agent | undefined { + const liveStatus = childThread?.activityRunStatus; + if (!agent || !liveStatus) return agent; + const startedAt = childThread.activityRunStartedAt; + return { + ...agent, + status: liveStatus === "running" || liveStatus === "waiting" ? liveStatus : "pending", + startedAt: startedAt ? DateTime.formatIso(startedAt) : null, + completedAt: null, + // The settled task's output belongs to its first run, not this one. + progress: null, + result: null, + error: null, + }; +} + export function ThreadRelationshipsPanel(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; @@ -239,9 +267,11 @@ export function ThreadRelationshipsPanel(props: { { id: "active", label: null, rows: active, expanded: true }, { id: "previous", label: "Previous agents", rows: previous, expanded: false }, ]; + // Subagents without a child thread yet have no row, so count them separately. const runningCount = - projection?.subagents.filter((agent) => agent.status === "running").length ?? - active.filter(({ edge }) => edge.status === "running").length; + (projection?.subagents.filter( + (agent) => agent.childThreadId === null && agent.status === "running", + ).length ?? 0) + active.filter(({ edge }) => edge.status === "running").length; if (relationshipRows.length === 0 && runningCount === 0) { return null; @@ -330,7 +360,10 @@ export function ThreadRelationshipsPanel(props: { ? BotIcon : GitForkIcon; const relationship = relationshipLabel(edge, props.threadId); - const agent = isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined; + const agent = liveSubagent( + isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined, + node?.thread, + ); const threadTitle = relationshipThreadTitle({ title: node?.thread?.title ?? agent?.title ?? threadId, isSubagent, diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index b7bf49654dcf..b2f0f6b9e762 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -5,12 +5,16 @@ import type { ThreadId, } from "@t3tools/contracts"; import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react"; -import { memo } from "react"; +import { memo, Suspense, use, useMemo } from "react"; +import { useTheme } from "../../hooks/useTheme"; +import { resolveDiffThemeName } from "../../lib/diffRendering"; +import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting"; import { useV2ItemSupport } from "../../state/v2ItemSupport"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; import { Button } from "../ui/button"; import ChatMarkdown from "../ChatMarkdown"; +import { RenderErrorBoundary } from "../RenderErrorBoundary"; import { resolveExternalWebLinkHref } from "./externalLinkContextMenu"; interface V2ItemInspectorProps { @@ -26,12 +30,53 @@ interface V2ItemInspectorProps { }) => void; } -function StructuredValue({ value }: { readonly value: unknown }) { +function JsonTokens({ text }: { readonly text: string }) { + const { resolvedTheme } = useTheme(); + const highlighter = use(getSyntaxHighlighterPromise("json")); + const { tokens } = useMemo( + () => + highlighter.codeToTokens(text, { lang: "json", theme: resolveDiffThemeName(resolvedTheme) }), + [highlighter, text, resolvedTheme], + ); + return tokens.flatMap((line, lineIndex) => [ + lineIndex > 0 ? "\n" : "", + ...line.map((token) => ( + + {token.content} + + )), + ]); +} + +function StructuredValue({ + value, + highlightJson = false, +}: { + readonly value: unknown; + readonly highlightJson?: boolean; +}) { const text = typeof value === "string" ? value : JSON.stringify(value, null, 2); + const isJson = useMemo(() => { + if (!highlightJson || !text) return false; + try { + JSON.parse(text); + return true; + } catch { + return false; + } + }, [highlightJson, text]); if (!text) return null; return (
    -      {text}
    +      {isJson ? (
    +        
    +          
    +            
    +          
    +        
    +      ) : (
    +        text
    +      )}
         
    ); } @@ -61,7 +106,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec {item.type === "command_execution" ? (
    - + {item.exitCode !== undefined ? (

    Process exited with code {item.exitCode} @@ -158,7 +203,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec

    Input

    - +
    ) : null} diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index ef9e8a3b6f8f..ea453a2b0c35 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -15,6 +15,7 @@ import { Fragment } from "react"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; import { ProviderDriverKind, + type OrchestrationV2Notification, type OrchestrationV2TurnItem, type ProviderInstanceId, type ServerProvider, @@ -326,6 +327,69 @@ export function SubagentAvatar({ ); } +/** The status a notification reported, frozen at the time it arrived. */ +const NOTIFICATION_OUTCOME_STATUS: Record< + OrchestrationV2Notification["outcome"], + OrchestrationV2TurnItem["status"] | null +> = { + completed: "completed", + failed: "failed", + cancelled: "cancelled", + updated: null, + unknown: null, +}; + +const NOTIFICATION_OUTCOME_LABEL: Record = { + completed: "Finished", + failed: "Failed", + cancelled: "Stopped", + updated: "Updated", + unknown: "Finished", +}; + +/** A notification about one subagent, drawn as that subagent's card. Renders `fallback` when the parent has no record of it. */ +export function SubagentNotificationLink(props: { + readonly parentRef: ScopedThreadRef; + readonly childThreadId: ThreadId; + readonly outcome: OrchestrationV2Notification["outcome"]; + readonly createdAt: string; + readonly timestampFormat: TimestampFormat; + readonly providerStatuses: ReadonlyArray; + readonly onOpenThread: (threadId: ThreadId) => void; + readonly fallback: ReactNode; +}) { + const agent = useAtomValue( + environmentThreadDetails.threadAtom(props.parentRef), + (thread) => + thread?.projection.subagents.find((agent) => agent.childThreadId === props.childThreadId) ?? + null, + ); + if (agent === null) return props.fallback; + return ( + provider.instanceId === agent.providerInstanceId, + )} + title={formatSubagentDisplayTitle(agent.title ?? "Subagent")} + result={agent.result} + progress={agent.progress} + startedAt={agent.startedAt} + completedAt={agent.completedAt} + threadId={props.childThreadId} + onOpenThread={props.onOpenThread} + event={{ + status: NOTIFICATION_OUTCOME_STATUS[props.outcome], + label: NOTIFICATION_OUTCOME_LABEL[props.outcome], + timestamp: formatShortTimestamp(props.createdAt, props.timestampFormat), + }} + /> + ); +} + function SubagentTimelineLink(props: { readonly parentRef: ScopedThreadRef; readonly subagentId: NodeId; @@ -339,35 +403,46 @@ function SubagentTimelineLink(props: { readonly completedAt: DateTime.Utc | null; readonly threadId: ThreadId | null; readonly onOpenThread: (threadId: ThreadId) => void; + /** Draws a past event about the subagent: its status then, and when it happened instead of elapsed time. */ + readonly event?: { + readonly status: OrchestrationV2TurnItem["status"] | null; + readonly label: string; + readonly timestamp: string; + }; }) { const agent = useAtomValue( environmentThreadDetails.threadAtom(props.parentRef), (thread) => thread?.projection.subagents.find((agent) => agent.id === props.subagentId) ?? null, ); const threadId = props.threadId; - const status = agent?.status ?? props.status; - const statusLabel = subagentStatusVisual(status).label; + const liveStatus = agent?.status ?? props.status; + const status = props.event ? props.event.status : liveStatus; + const statusLabel = props.event?.label ?? subagentStatusVisual(liveStatus).label; const result = (agent?.result ?? props.result)?.trim(); const progress = (agent?.progress ?? props.progress)?.trim(); - const settled = SETTLED_SUBAGENT_STATUSES.has(status); + const settled = SETTLED_SUBAGENT_STATUSES.has(status ?? liveStatus); const rawDetail = settled ? result || progress : progress || result; const detail = rawDetail && !GENERIC_CHILD_END.test(rawDetail) ? plainDetail(rawDetail) || null : null; const failed = status === "failed"; const timing = { - status, + status: liveStatus, startedAt: isoOrNull(agent?.startedAt ?? props.startedAt), completedAt: isoOrNull(agent?.completedAt ?? props.completedAt), }; const content = ( <> - + {props.title} - {detail !== null && status !== "completed" ? ( + {detail !== null && (props.event !== undefined || status !== "completed") ? ( - + {props.event ? props.event.timestamp : } {threadId !== null ? ( : null} model={agent?.model ?? null} - status={status} + status={liveStatus} result={agent?.result ?? props.result} progress={agent?.progress ?? props.progress} /> diff --git a/apps/web/src/components/chat/WorkLog.tsx b/apps/web/src/components/chat/WorkLog.tsx index b09731baeded..bc7a4d0d6683 100644 --- a/apps/web/src/components/chat/WorkLog.tsx +++ b/apps/web/src/components/chat/WorkLog.tsx @@ -64,7 +64,7 @@ function WorkLogLine({ icon, label, trailing, wrapLabel }: RowContent) { } const interactionClassName = - "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"; + "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"; function useRowClassName(interactive: boolean) { const grouped = use(GroupedRows); @@ -119,9 +119,9 @@ export function WorkLogDetails({ return (
    setDetailsOpen((open) => !open)} > - {detailsOpen ? : } + Details {showTerminal ? ( diff --git a/apps/web/src/components/device/DeviceControlsRail.tsx b/apps/web/src/components/device/DeviceControlsRail.tsx index 3b9e8bf96c36..3ff3968b5ce0 100644 --- a/apps/web/src/components/device/DeviceControlsRail.tsx +++ b/apps/web/src/components/device/DeviceControlsRail.tsx @@ -6,7 +6,6 @@ import { Keyboard, Box, Maximize, - Moon, MoreHorizontal, PictureInPicture2, Power, @@ -14,12 +13,13 @@ import { SlidersHorizontal, Smartphone, Square, - Sun, Type, X, } from "lucide-react"; +import { Moon, Sun } from "lucide"; import type { ReactNode } from "react"; import { Button } from "~/components/ui/button"; +import { MorphIcon } from "~/components/MorphIcon"; import { Menu, MenuItem, @@ -132,7 +132,7 @@ export function DeviceControlsRail(props: { disabled={controls.disabled || !settings?.appearance} onClick={() => void controls.act({ type: "setAppearance", value: nextAppearance })} > - {settings?.appearance === "dark" ? : } + -

    {props.platform}

    diff --git a/apps/web/src/components/diffs/DiffFileTree.tsx b/apps/web/src/components/diffs/DiffFileTree.tsx index 9108c0556cc9..b7060cfece45 100644 --- a/apps/web/src/components/diffs/DiffFileTree.tsx +++ b/apps/web/src/components/diffs/DiffFileTree.tsx @@ -1,6 +1,6 @@ import type { GitStatusEntry } from "@pierre/trees"; import { FileTree, useFileTree, useFileTreeSelector } from "@pierre/trees/react"; -import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; +import { ChevronsDownUp, ChevronsUpDown } from "lucide"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useTheme } from "~/hooks/useTheme"; @@ -10,6 +10,7 @@ import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "../files/fileTreeExpansion"; import { Button } from "../ui/button"; +import { MorphIcon } from "~/components/MorphIcon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { buildDiffFileTreeUpdates, @@ -189,11 +190,10 @@ export function DiffFileTree({ /> } > - {allDirectoriesExpanded ? ( - - ) : ( - - )} + {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index 3152e4f4a9c5..2d939d714ed8 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -3,22 +3,13 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { readFilePreviewResponse } from "@t3tools/client-runtime/file-preview"; import { filePreviewKind, FILE_TEXT_PREVIEW_MAX_BYTES } from "@t3tools/shared/filePreview"; -import { - CheckIcon, - ChevronRightIcon, - Code2, - CopyIcon, - DownloadIcon, - Eye, - Table2, - Trash2Icon, - WrapTextIcon, - XIcon, -} from "lucide-react"; +import { ChevronRightIcon, DownloadIcon, Trash2Icon, WrapTextIcon, XIcon } from "lucide-react"; +import { Check, Code2, Copy, Eye, Table2 } from "lucide"; import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; import { useAssetUrlRefresh } from "~/assets/assetUrls"; import ChatMarkdown from "~/components/ChatMarkdown"; +import { MorphIcon } from "~/components/MorphIcon"; import { ScrollArea } from "~/components/ui/scroll-area"; import { toastManager } from "~/components/ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; @@ -305,13 +296,10 @@ export function AttachmentFilePreview(props: { pressed={rendered} onPress={() => setRendered((value) => !value)} > - {rendered ? ( - - ) : renderedMode === "table" ? ( - - ) : ( - - )} + ) : null} {showsRawText ? ( @@ -328,7 +316,7 @@ export function AttachmentFilePreview(props: { label={isCopied ? "Copied" : content.truncated ? "Copy preview" : "Copy contents"} onPress={() => copyToClipboard(content.text, undefined)} > - {isCopied ? : } + ) : null} {url ? ( diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 69e8714a3298..ad134abf8904 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -6,11 +6,12 @@ import type { import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; -import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; +import { ChevronsDownUp, ChevronsUpDown } from "lucide"; import { useEffect, useMemo, useRef, useState } from "react"; import { Button } from "~/components/ui/button"; import { InputGroup, InputGroupInput } from "~/components/ui/input-group"; +import { MorphIcon } from "~/components/MorphIcon"; import { toastManager } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useComposerHandleContext } from "~/composerHandleContext"; @@ -516,11 +517,10 @@ export default function FileBrowserPanel({ /> } > - {allDirectoriesExpanded ? ( - - ) : ( - - )} + {expandAll || allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 313ccf0b7df8..8fff3edccc45 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -21,7 +21,8 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; -import { Code2, Eye, FolderTree, Globe2, Table2, WrapTextIcon } from "lucide-react"; +import { FolderTree, Globe2, WrapTextIcon } from "lucide-react"; +import { Code2, Eye, Table2 } from "lucide"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -30,6 +31,7 @@ import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer"; import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions"; +import { MorphIcon } from "~/components/MorphIcon"; import { useRemoteOpenState } from "~/remoteOpen"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; @@ -1140,13 +1142,10 @@ export default function FilePreviewPanel({ ); }} > - {rendered ? ( - - ) : renderedMode === "table" ? ( - - ) : ( - - )} + ) : null} {showsRawText ? ( diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 219210023e2e..a50a99193585 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -26,11 +26,11 @@ import { CheckIcon, ChevronRightIcon, CloudIcon, - CopyIcon, LinkIcon, MonitorIcon, TerminalIcon, } from "lucide-react"; +import { Check, Copy } from "lucide"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts"; @@ -77,6 +77,7 @@ import { T3Wordmark } from "../T3Wordmark"; import { Alert, AlertDescription } from "../ui/alert"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; +import { MorphIcon } from "~/components/MorphIcon"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Input } from "../ui/input"; import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; @@ -1744,7 +1745,7 @@ function CommandBlock({ aria-label="Copy command" onClick={() => copyToClipboard(command, undefined)} > - {isCopied ? : } +
    ); diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 251f3cd49f95..ae8a2b2d344e 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -13,8 +13,6 @@ import type { import { ChevronDownIcon, ChevronRightIcon, - ChevronsDownUpIcon, - ChevronsUpDownIcon, Columns2Icon, FolderTreeIcon, InfoIcon, @@ -24,6 +22,7 @@ import { TextWrapIcon, TriangleAlertIcon, } from "lucide-react"; +import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide"; import { useAtomRefresh } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; @@ -64,6 +63,7 @@ import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal"; import { diffFileTreeEntries } from "../diffs/diffFileTree.logic"; import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView"; import { Button } from "../ui/button"; +import { MorphIcon } from "~/components/MorphIcon"; import { Checkbox } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { @@ -791,11 +791,7 @@ function PullRequestCodeTab({ toggleFile(item.id); }} > - {collapsed ? ( - - ) : ( - - )} + ); }, @@ -1249,11 +1245,10 @@ function PullRequestCodeTab({ /> } > - {allFilesCollapsed ? ( - - ) : ( - - )} + {allFilesCollapsed ? "Expand all files" : "Collapse all files"} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 5c82e03aa8ea..bf5bd62de91c 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -144,6 +144,7 @@ import { handoffReviewComments, latestPullRequestReviewOutcomes, loadingPullRequestCheckoutCommand, + isPullRequestNotFound, isStackedPullRequestBase, pullRequestActionMenuHasGroup, pullRequestActionNeedsHostRefresh, @@ -2719,7 +2720,13 @@ export function PullRequestDetailPanel({ > {detailQuery.error && !detail ? ( event.stopPropagation()} >
    - {thread.isResolved ? ( - - ) : ( - - )} + ) : (
    - {open ? ( - - ) : ( - - )} + {open ? collapseLabel : expandLabel} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 1f0db7ed5692..46f44c12239d 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -82,6 +82,7 @@ import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog"; import { UsageShareBar } from "./UsageShareBar"; import { costTypeSegments, + modelShare, sortModelsByTokens, speedCostSegments, tokenTypeSegments, @@ -700,6 +701,10 @@ export function UsagePage() { breakdownModels.map((model, index) => { const key = `${model.provider}:${model.model}`; const value = metric === "tokens" ? model.totalTokens : model.costUsd; + const share = modelShare( + model, + metric === "tokens" ? "tokens" : "cost", + ); return ( - {isModelCostUnknown(model) ? "" : formatPercent(model.costShare)} + {share === null ? "" : formatPercent(share)} {formatTokens(model.totalTokens)} diff --git a/apps/web/src/components/usage/UsagePriceOverrides.tsx b/apps/web/src/components/usage/UsagePriceOverrides.tsx index c9af1c0e1770..0b52687c81b0 100644 --- a/apps/web/src/components/usage/UsagePriceOverrides.tsx +++ b/apps/web/src/components/usage/UsagePriceOverrides.tsx @@ -34,6 +34,7 @@ import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { USAGE_PRICE_FIELDS } from "./usagePriceForm"; import { isEmptyUsagePriceDraft, + usageAliasCell, usagePriceCell, usagePriceTableChanges, usagePriceTableErrors, @@ -63,6 +64,10 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] => environmentId, label: environment.entry.target.label, prices: settings?.usagePriceOverrides ?? null, + aliases: + environment.serverConfig?.environment.capabilities.usageModelAliases === true + ? (settings?.usageModelAliases ?? null) + : null, unavailable: environment.connection.phase !== "connected" ? "Offline" @@ -91,7 +96,10 @@ type SaveAttempt = { >; }; -/** Edits custom model prices. `initialModel` opens with a new row for that model. */ +/** + * Edits custom model prices and mappings. A mapped model's usage counts as its + * target model. `initialModel` opens with a new row for that model. + */ export function UsagePriceOverrides({ usage, initialSelectedEnvironmentIds, @@ -108,12 +116,13 @@ export function UsagePriceOverrides({ const selected = environments.filter( (environment) => selectedIds === null || selectedIds.has(environment.environmentId), ); - // A model that already has a custom price somewhere is edited in its existing row. + // A model that already has a custom price or mapping is edited in its existing row. const [drafts, setDrafts] = useState(() => initialModel === undefined || selected.some( (environment) => - environment.prices !== null && Object.hasOwn(environment.prices, initialModel), + Object.hasOwn(environment.prices ?? {}, initialModel) || + Object.hasOwn(environment.aliases ?? {}, initialModel), ) ? [] : [{ id: "new:initial", model: initialModel, isNew: true, values: {} }], @@ -124,7 +133,12 @@ export function UsagePriceOverrides({ const nextRowId = useRef(0); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); const customModels = [ - ...new Set(selected.flatMap((environment) => Object.keys(environment.prices ?? {}))), + ...new Set( + selected.flatMap((environment) => [ + ...Object.keys(environment.prices ?? {}), + ...Object.keys(environment.aliases ?? {}), + ]), + ), ].sort(); const models = [ ...new Set([ @@ -191,19 +205,31 @@ export function UsagePriceOverrides({ ); setAttempt(null); }; + // An existing row edited back to its saved values has nothing left to save. + const matchesSaved = ( + row: UsagePriceDraft, + cell: { value: string; placeholder: string }, + value: string, + ) => + !row.isNew && + cell.placeholder !== "Mixed" && + cell.placeholder !== "Unavailable" && + value === cell.value; + const updateOrDropDraft = (draft: UsagePriceDraft) => { + if (!draft.isNew && Object.keys(draft.values).length === 0 && draft.alias === undefined) + setDrafts((previous) => previous.filter((entry) => entry.id !== draft.id)); + else updateDraft(draft); + }; const editCell = (row: UsagePriceDraft, field: UsagePriceField, value: string) => { const values = { ...row.values, [field]: value }; const original = usagePriceCell(selected, row.model, field); - if ( - !row.isNew && - original.placeholder !== "Mixed" && - original.placeholder !== "Unavailable" && - value === original.value - ) - delete values[field]; - if (!row.isNew && Object.keys(values).length === 0) - setDrafts((previous) => previous.filter((entry) => entry.id !== row.id)); - else updateDraft({ ...row, values }); + if (matchesSaved(row, original, value)) delete values[field]; + updateOrDropDraft({ ...row, values }); + }; + const editAlias = (row: UsagePriceDraft, value: string) => { + const { alias: _alias, ...rest } = row; + const original = usageAliasCell(selected, row.model); + updateOrDropDraft(matchesSaved(row, original, value) ? rest : { ...rest, alias: value }); }; const save = async (retry = false) => { if (pending) return; @@ -214,7 +240,7 @@ export function UsagePriceOverrides({ (destination) => environments.find( (environment) => environment.environmentId === destination.environmentId, - ) ?? { ...destination, prices: null, unavailable: "Environment removed" }, + ) ?? { ...destination, prices: null, aliases: null, unavailable: "Environment removed" }, ); const changes = new Map( targets.map((target) => [target.environmentId, usagePriceTableChanges(target, edits)]), @@ -257,11 +283,11 @@ export function UsagePriceOverrides({ if (!pending) onOpenChange(open); }} > - + Custom model prices - Prices apply to all past and future usage on the environments you select. + Prices and mappings apply to all past and future usage on the environments you select. @@ -326,9 +352,10 @@ export function UsagePriceOverrides({ ) : ( <>
    - +
    - + + {USAGE_PRICE_FIELDS.map((field) => ( ))} @@ -337,6 +364,7 @@ export function UsagePriceOverrides({ Model ID + Map to {USAGE_PRICE_FIELDS.map((field) => ( {field.label} ))} @@ -360,132 +388,157 @@ export function UsagePriceOverrides({ {rows.length === 0 ? ( - +

    {selected.some((environment) => environment.prices === null) ? "Some environment prices are unavailable." - : "No custom prices. Add a row to override automatic pricing."} + : "No custom prices or mappings. Add a row to set one."}

    ) : ( - rows.map((row) => ( - - - {row.isNew ? ( - { - if (node && focusRowRef.current === row.id) { - node.focus(); - focusRowRef.current = null; + rows.map((row) => { + const aliasCell = usageAliasCell(selected, row.model); + const alias = (row.alias ?? aliasCell.value).trim(); + return ( + + + {row.isNew ? ( + { + if (node && focusRowRef.current === row.id) { + node.focus(); + focusRowRef.current = null; + } + }} + aria-label="New model ID" + aria-invalid={ + (row.model.trim() !== "" && errors.has(row.id)) || undefined + } + list="usage-price-models" + placeholder="Model ID" + autoComplete="off" + spellCheck={false} + disabled={locked} + onChange={(event) => + updateDraft({ ...row, model: event.target.value }) } - }} - aria-label="New model ID" - aria-invalid={ - (row.model.trim() !== "" && errors.has(row.id)) || undefined - } - list="usage-price-models" - placeholder="Model ID" - autoComplete="off" - spellCheck={false} - disabled={locked} - onChange={(event) => - updateDraft({ ...row, model: event.target.value }) - } - /> + /> + ) : ( + + {row.model} + + )} + {errors.has(row.id) && + (row.model.trim() !== "" || + Object.values(row.values).some((value) => value !== "")) ? ( +

    + {errors.get(row.id)} +

    + ) : null} +
    + {row.removed ? ( + + + Automatic pricing after saving + + ) : ( - - {row.model} - + + editAlias(row, event.target.value)} + /> + )} - {errors.has(row.id) && - (row.model.trim() !== "" || - Object.values(row.values).some((value) => value !== "")) ? ( -

    - {errors.get(row.id)} -

    - ) : null} -
    - {row.removed ? ( - - - Automatic pricing after saving - - - ) : ( - USAGE_PRICE_FIELDS.map((field) => { - const cell = usagePriceCell(selected, row.model, field.key); - return ( - - - editCell(row, field.key, event.target.value) - } - /> - - ); - }) - )} - - - } - disabled={locked} - aria-label={ - row.removed - ? `Undo reset for ${row.model}` - : row.isNew - ? "Remove new model" - : `Reset price for ${row.model} to automatic` - } - onClick={() => { - if (row.isNew) - setDrafts((previous) => - previous.filter((entry) => entry.id !== row.id), - ); - else if (row.removed) { - if (Object.keys(row.values).length === 0) + {row.removed ? null : alias !== "" ? ( + + + Counted as {alias} + + + ) : ( + USAGE_PRICE_FIELDS.map((field) => { + const cell = usagePriceCell(selected, row.model, field.key); + return ( + + + editCell(row, field.key, event.target.value) + } + /> + + ); + }) + )} + + + } + disabled={locked} + aria-label={ + row.removed + ? `Undo reset for ${row.model}` + : row.isNew + ? "Remove new model" + : `Reset price for ${row.model} to automatic` + } + onClick={() => { + if (row.isNew) setDrafts((previous) => previous.filter((entry) => entry.id !== row.id), ); - else updateDraft({ ...row, removed: false }); - } else updateDraft({ ...row, removed: true }); - }} - > - {row.isNew ? : } - - - {row.removed - ? "Undo reset" - : row.isNew - ? "Remove row" - : "Reset to automatic"} - - - -
    - )) + else if (row.removed) + updateOrDropDraft({ ...row, removed: false }); + else updateDraft({ ...row, removed: true }); + }} + > + {row.isNew ? ( + + ) : ( + + )} + + + {row.removed + ? "Undo reset" + : row.isNew + ? "Remove row" + : "Reset to automatic"} + + + +
    + ); + }) )}
    @@ -497,8 +550,20 @@ export function UsagePriceOverrides({