diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index aeab1b1cf6..ff2e98fb90 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -15,7 +15,7 @@ The maintainer authorized compatible catch-up and routine adaptations that prese ## Active cycle -[Upstream integration cycle #598](https://github.com/pylon-code/pylon/issues/598) owns the next five thread-reading and preview fixes within frozen head `6d1d549441be84f19696ab59ed7e2fbf305280d4`. Cycles #585, #591 and #594 are complete. Merge is authorized after review and green verification; the review cursor remains unchanged. +[Upstream integration cycle #605](https://github.com/pylon-code/pylon/issues/605) owns worktree setup progress, cancellation and mobile handoff within frozen head `6d1d549441be84f19696ab59ed7e2fbf305280d4`. The [existing-port audit #604](https://github.com/pylon-code/pylon/issues/604) reconciles 22 sources already present and records the remaining range after cycle #598. Merge is authorized after review and green verification; the review cursor remains unchanged. ## Latest cycle @@ -191,3 +191,5 @@ The review cursor stays unchanged; unrelated sources in this range remain unclas | Thread reading positions and visible answers / `6d1d549441be84f19696ab59ed7e2fbf305280d4` | `f17165a76b1a782831eb6a05a4383b27283df15d` (#12144), `3486451525ecaf3a8379aa6d08c885c7cc5335e3` (#11363), `03e135577cbf861c2948981601c070dab038ea11` (#11461) | Adopted with Pylon delegation rows, terminal notices, action-required prominence and static working/delegating treatment retained. Session-scoped reading anchors and existing turn/work disclosures restore across navigation; citations and manual gestures take priority. Missing rows fall back to offsets; measured correction is bounded. No reload persistence or native mobile position cache; mobile answer visibility is included. | Cycle #598; implementation PR records review, focused tests, scoped checks and isolated browser evidence. | | Preview identity and focus recovery / `6d1d549441be84f19696ab59ed7e2fbf305280d4` | `c542b781c6c4c766e66dcb764b2142d95600e890` (#7110), `cfeaca41ae27bdf2c203158d378c87c7308fea2a` (#11444) | Adopted: preview sessions retain native Electron/Pylon User-Agent and window-return refocus leaves iframe/webview focus alone. Permission policy and session ownership unchanged. Sites can identify the native embedded browser; no claim of bypassing third-party authentication policies. | Cycle #598; implementation PR records effective-UA regression, focus tests, types/lint and interaction evidence. | + +| Worktree setup progress and cancellation / `6d1d549441be84f19696ab59ed7e2fbf305280d4` | Ten full source SHAs enumerated in [PR #608](https://github.com/pylon-code/pylon/pull/608), from #6208 and #11372 through #12177 | Adopted with Pylon admission, source epochs, compaction, inline context and mobile outbox retained. Live stages, required/background scripts, prompt persistence, cancellation and draft recovery span web and mobile. Capability-gated subscriptions support older hosts; setup history is bounded, and server restart ends live tracking. iOS uses the existing React Native modal layout so setup actions receive touches. | Cycle #605; PR #608 records combined review, regression coverage, scoped checks and uploaded web/iOS evidence. | diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index ca36d377eb..ad5c6c4a52 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,3 +1,4 @@ +import type { WorktreeSetupCardProps } from "./worktree-setup-card"; import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import type { ContextWindowSnapshot } from "@t3tools/client-runtime/state/context-window"; import { @@ -132,6 +133,8 @@ import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; export interface ThreadDetailScreenProps { + readonly worktreeSetup?: WorktreeSetupCardProps | null; + readonly setupWorkingStartedAt?: string | null; readonly selectedThread: OrchestrationThreadShell; readonly contentPresentation: ThreadContentPresentation; readonly screenTone: StatusTone; @@ -498,6 +501,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return null; } if (props.creationState?.kind === "preparing") { + // The setup header already reports progress in the feed. + if (props.worktreeSetup) return null; return { kind: "preparing", label: props.creationState.preparingWorktree ? "Setting up worktree…" : "Starting…", @@ -992,6 +997,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread threadId={props.selectedThread.id} workspaceRoot={props.threadCwd} feed={props.selectedThreadFeed} + worktreeSetup={props.worktreeSetup} + setupWorkingStartedAt={props.setupWorkingStartedAt} queuedMessages={props.queuedMessages} dispatchingMessageId={props.dispatchingMessageId} onEditPendingMessage={handleEditPendingMessage} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 626a59b311..2d5863aa41 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,4 +1,9 @@ import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; +import { + WorktreeWorkingHeader, + WorktreeSetupCard, + type WorktreeSetupCardProps, +} from "./worktree-setup-card"; import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; @@ -234,6 +239,8 @@ function isFreshTimestamp(input: string): boolean { } export interface ThreadFeedProps { + readonly worktreeSetup?: WorktreeSetupCardProps | null; + readonly setupWorkingStartedAt?: string | null; readonly queuedMessages: ReadonlyArray; readonly dispatchingMessageId: MessageId | null; readonly onEditPendingMessage: (message: QueuedThreadMessage) => void; @@ -2262,6 +2269,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // even when the final message update arrives before the turn settles. const listAppearanceData = useMemo( () => ({ + worktreeSetup: props.worktreeSetup, + setupWorkingStartedAt: props.setupWorkingStartedAt, dispatchingMessageId: props.dispatchingMessageId, unsettledTurnId, copiedRowId, @@ -2275,6 +2284,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { viewportWidth, }), [ + props.worktreeSetup, + props.setupWorkingStartedAt, props.dispatchingMessageId, unsettledTurnId, copiedRowId, @@ -2441,6 +2452,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.latestTurn, ], ); + const setupAnchorIndex = presentedFeed.findIndex( + (entry) => entry.type === "message" && entry.message.role === "user", + ); // The empty↔filled key below remounts the list and resets its imperative // content-inset override. Seed the fresh instance synchronously with the // current overlay height before the scroll integration's next reaction; @@ -2725,10 +2739,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { rollbackCommandPending: props.rollbackCommandPending, onRevertMessage: props.onRevertMessage, })} + {props.worktreeSetup && info.index === setupAnchorIndex ? ( + + ) : props.setupWorkingStartedAt && info.index === setupAnchorIndex ? ( + + ) : null} ), [ + props.worktreeSetup, + props.setupWorkingStartedAt, + props.threadId, + setupAnchorIndex, props.dispatchingMessageId, props.onEditPendingMessage, copiedRowId, @@ -2913,6 +2936,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ListHeaderComponent={ <> {usesNativeAutomaticInsets ? null : } + {setupAnchorIndex < 0 && props.worktreeSetup ? ( + + ) : null} {props.loadEarlier != null ? ( {presentedFeed.length === 0 && + !props.worktreeSetup && props.activeWorkStartedAt === null && props.contentPresentation.kind === "ready" ? ( diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 3164089ef2..729430f1f7 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1,3 +1,11 @@ +import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; +import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; +import { + getComposerDraftSnapshot, + clearComposerDraftContent, +} from "../../state/use-composer-drafts"; +import { useWorktreeSetup } from "./use-worktree-setup"; +import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, @@ -8,6 +16,8 @@ import { import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; import { + CommandId, + MessageId, DEFAULT_SERVER_SETTINGS, EnvironmentId, ThreadId, @@ -1013,9 +1023,103 @@ function ThreadRouteContent( }), ); }, [navigation, selectedThreadCreation, selectedThreadProject]); + const worktreeSetup = useWorktreeSetup({ + environmentId: selectedThread?.environmentId ?? null, + threadId: selectedThread?.id ?? null, + activities: selectedThreadDetail?.activities ?? [], + preparing: + selectedThreadCreation?.message.creation?.workspaceMode === "worktree" && + selectedThreadCreation.outcome == null, + turnStarted: selectedThreadDetail?.latestTurn?.startedAt != null, + followUpSent: + composer.selectedThreadFeed.filter( + (entry) => entry.type === "message" && entry.message.role === "user", + ).length + + composer.selectedThreadQueuedMessages.length > + 1, + }); + const awaitingBootstrapTurn = + worktreeSetup?.phase === "running" && !worktreeSetupAgentStarted(worktreeSetup); + const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup); + const handleCancelWorktreeSetup = useCallback(() => { + if (!selectedThread) return; + void cancelWorktreeSetup({ + environmentId: selectedThread.environmentId, + input: { threadId: selectedThread.id }, + }); + }, [cancelWorktreeSetup, selectedThread]); + const [localResendMessageId, setLocalResendMessageId] = useState(null); + const handleWorkLocally = useCallback(async () => { + if (!selectedThread || !selectedThreadCreation) return; + const result = await cancelWorktreeSetup({ + environmentId: selectedThread.environmentId, + input: { threadId: selectedThread.id }, + }); + if (result._tag === "Success" && result.value.cancelled) { + setLocalResendMessageId(selectedThreadCreation.message.messageId); + } + }, [cancelWorktreeSetup, selectedThread, selectedThreadCreation]); + // Wait for the outbox to restore the cancelled send before queuing its replacement. + useEffect(() => { + const pending = selectedThreadCreation; + if ( + !localResendMessageId || + pending?.message.messageId !== localResendMessageId || + pending.outcome?.kind !== "failed" + ) + return; + setLocalResendMessageId(null); + const original = pending.message; + if (!original.creation) return; + const metadata = makeTurnCommandMetadata(); + const replacement = { + ...original, + commandId: CommandId.make(metadata.commandId), + messageId: MessageId.make(metadata.messageId), + threadId: ThreadId.make(metadata.threadId), + createdAt: metadata.createdAt, + creation: { + ...original.creation, + workspaceMode: "local" as const, + branch: null, + worktreePath: null, + }, + }; + void enqueueThreadOutboxMessage(replacement) + .then(() => { + const draftKey = restoredNewTaskDraftKey(original.messageId); + const restored = getComposerDraftSnapshot(draftKey); + // Leave any edits made during cancellation in their recovery draft. + if ( + restored.text === original.text && + JSON.stringify(restored.context) === JSON.stringify(original.context) && + restored.attachments.length === original.attachments.length && + restored.attachments.every( + (attachment, index) => attachment.id === original.attachments[index]?.id, + ) + ) { + clearComposerDraftContent(draftKey, { deferAttachmentCleanup: true }); + } + clearPendingThreadCreationOutcome( + scopedThreadKey(original.environmentId, original.threadId), + ); + navigation.dispatch( + StackActions.replace("Thread", { + environmentId: String(replacement.environmentId), + threadId: String(replacement.threadId), + }), + ); + }) + .catch((error) => + Alert.alert( + "Could not work locally", + error instanceof Error ? error.message : String(error), + ), + ); + }, [localResendMessageId, navigation, selectedThreadCreation]); const creationState = ((): ThreadDetailScreenProps["creationState"] => { if (selectedThreadCreation === null) { - return null; + return awaitingBootstrapTurn ? { kind: "preparing", preparingWorktree: true } : null; } const hold = selectedThreadCreation.outcome === null @@ -1119,6 +1223,32 @@ function ThreadRouteContent( activeWorkStartedAt={composer.activeWorkStartedAt} isCompacting={composer.isCompacting} creationState={creationState} + setupWorkingStartedAt={ + composer.activeWorkStartedAt !== null && + selectedThreadDetail?.activities.some( + (activity) => activity.kind === "worktree-setup", + ) && + composer.selectedThreadFeed.filter( + (entry) => entry.type === "message" && entry.message.role === "user", + ).length <= 1 + ? composer.activeWorkStartedAt + : null + } + worktreeSetup={ + worktreeSetup + ? { + snapshot: worktreeSetup, + turnStartedAt: selectedThreadDetail?.latestTurn?.startedAt ?? null, + working: composer.activeWorkStartedAt !== null, + turnStarted: selectedThreadDetail?.latestTurn?.startedAt != null, + onCancel: handleCancelWorktreeSetup, + onWorkLocally: + selectedThreadCreation?.outcome == null && selectedThreadCreation + ? handleWorkLocally + : null, + } + : null + } activePendingApproval={requests.activePendingApproval} respondingApprovalId={requests.respondingApprovalId} activePendingUserInput={requests.activePendingUserInput} @@ -1158,7 +1288,7 @@ function ThreadRouteContent( onNativePasteImages={composer.onNativePasteImages} onRemoveDraftImage={composer.onRemoveDraftImage} serverConfig={serverConfig} - onStopThread={handleStopThread} + onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread} onReloadSessionResources={handleReloadSessionResources} onRefineSessionHarness={handleRefineSessionHarness} onAskSessionSideQuestion={handleAskSessionSideQuestion} diff --git a/apps/mobile/src/features/threads/use-worktree-setup.ts b/apps/mobile/src/features/threads/use-worktree-setup.ts new file mode 100644 index 0000000000..c074ea7be1 --- /dev/null +++ b/apps/mobile/src/features/threads/use-worktree-setup.ts @@ -0,0 +1,57 @@ +import type { EnvironmentId, ThreadId, WorktreeSetupSnapshot } from "@t3tools/contracts"; +import { + findRecordedWorktreeSetup, + resolveVisibleWorktreeSetup, +} from "@t3tools/client-runtime/worktree-setup"; +import { useAtomValue } from "@effect/atom-react"; +import { environmentServerConfigsAtom } from "../../state/server"; +import { useEffect, useState } from "react"; +import { useEnvironmentQuery } from "../../state/query"; +import { vcsEnvironment } from "../../state/vcs"; + +/** Retain the last live snapshot when its subscription closes after setup. */ +export function useWorktreeSetup(input: { + environmentId: EnvironmentId | null; + threadId: ThreadId | null; + activities: ReadonlyArray<{ kind: string; payload: unknown }>; + preparing: boolean; + turnStarted: boolean; + followUpSent: boolean; +}) { + const configs = useAtomValue(environmentServerConfigsAtom); + const supported = + input.environmentId !== null && + configs.get(input.environmentId)?.environment.capabilities.worktreeSetupTracking === true; + const key = JSON.stringify([input.environmentId, input.threadId]); + const [held, setHeld] = useState<{ key: string; snapshot: WorktreeSetupSnapshot } | null>(null); + const live = held?.key === key ? held.snapshot : null; + const recorded = input.threadId + ? findRecordedWorktreeSetup(input.activities, input.threadId) + : null; + const latest = resolveVisibleWorktreeSetup({ + live, + recorded, + turnStarted: false, + followUpSent: false, + }); + const query = useEnvironmentQuery( + supported && + input.environmentId && + input.threadId && + (latest?.phase === "running" || (!latest && input.preparing)) + ? vcsEnvironment.worktreeSetup({ + environmentId: input.environmentId, + input: { threadId: input.threadId }, + }) + : null, + ); + useEffect(() => { + if (query.data?.threadId === input.threadId) setHeld({ key, snapshot: query.data }); + }, [key, input.threadId, query.data]); + return resolveVisibleWorktreeSetup({ + live: query.data ?? live, + recorded, + turnStarted: input.turnStarted, + followUpSent: input.followUpSent, + }); +} diff --git a/apps/mobile/src/features/threads/worktree-setup-card.tsx b/apps/mobile/src/features/threads/worktree-setup-card.tsx new file mode 100644 index 0000000000..536dcee840 --- /dev/null +++ b/apps/mobile/src/features/threads/worktree-setup-card.tsx @@ -0,0 +1,433 @@ +import { + worktreeSetupStageLabel, + type WorktreeSetupSnapshot, + type WorktreeSetupStage, +} from "@t3tools/contracts"; +import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; +import { formatDuration } from "@t3tools/shared/orchestrationTiming"; +import { useEffect, useState } from "react"; +import { useIsFocused } from "@react-navigation/native"; +import { + AccessibilityInfo, + ActivityIndicator, + AppState, + Pressable, + ScrollView, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AppText as Text } from "../../components/AppText"; +import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; +import { WorktreeSetupSheet } from "./worktree-setup-sheet"; +import { ShimmeringWorkContent } from "./thread-work-log"; + +export interface WorktreeSetupCardProps { + snapshot: WorktreeSetupSnapshot; + turnStarted: boolean; + turnStartedAt: string | null; + working: boolean; + onCancel: () => void; + onWorkLocally: (() => void) | null; +} + +function elapsed(start: string | null, end: string | null, now: number) { + const duration = (end ? Date.parse(end) : now) - (start ? Date.parse(start) : NaN); + return Number.isFinite(duration) ? formatDuration(Math.max(0, duration)) : null; +} + +const icons: Record = { + pending: "circle", + running: "clock", + done: "checkmark", + skipped: "minus", + failed: "xmark", + warning: "exclamationmark.triangle", +}; + +/** Setup stages collapse into the working header once the agent's turn is live. */ +export function WorktreeSetupCard(props: WorktreeSetupCardProps) { + const { snapshot, turnStarted, turnStartedAt, working } = props; + const motionActive = useSetupMotionActive(); + const handedOff = turnStarted && worktreeSetupAgentStarted(snapshot); + const running = snapshot.phase === "running"; + const backgroundSetup = handedOff && running; + const scriptName = snapshot.setupScript?.name ?? "Setup script"; + const [detailsOpen, setDetailsOpen] = useState(false); + const now = useSetupClock(running || working); + const failed = + snapshot.phase === "failed" || snapshot.stages.some((stage) => stage.status === "failed"); + const label = + handedOff && working + ? `Working for ${elapsed(turnStartedAt, null, now) ?? "0s"}` + : running + ? handedOff + ? "Setup continues…" + : "Setting up worktree…" + : snapshot.phase === "cancelled" + ? "Worktree setup cancelled" + : snapshot.phase === "failed" + ? "Worktree setup failed" + : failed + ? "Setup script failed" + : "Worktree ready"; + + return ( + + + + {!handedOff ? ( + + {elapsed(snapshot.startedAt, snapshot.endedAt, now)} + + ) : null} + setDetailsOpen(true)} + className="min-h-11 max-w-[55%] justify-center px-2" + > + + {backgroundSetup && motionActive ? ( + + ) : failed ? ( + + ) : null} + + {backgroundSetup ? scriptName : "Details"} + + {!backgroundSetup ? ( + + ) : null} + + + + {!handedOff ? ( + + {snapshot.stages + .filter((stage) => stage.id !== "agent") + .map((stage) => ( + + ))} + + ) : null} + {detailsOpen ? ( + setDetailsOpen(false)} /> + ) : null} + + ); +} + +export function WorktreeWorkingHeader({ startedAt }: { startedAt: string }) { + const now = useSetupClock(true); + return ( + + + + + + ); +} + +function HeaderLabel({ + label, + active, + failed = false, +}: { + label: string; + active: boolean; + failed?: boolean; +}) { + return active ? ( + + ) : ( + + {label} + + ); +} + +function useSetupMotionActive() { + const focused = useIsFocused(); + const [active, setActive] = useState(AppState.currentState === "active"); + const [reducedMotion, setReducedMotion] = useState(true); + useEffect(() => { + const appSubscription = AppState.addEventListener("change", (state) => + setActive(state === "active"), + ); + let mounted = true; + void AccessibilityInfo.isReduceMotionEnabled().then((reduced) => { + if (mounted) setReducedMotion(reduced); + }); + const motionSubscription = AccessibilityInfo.addEventListener( + "reduceMotionChanged", + setReducedMotion, + ); + return () => { + mounted = false; + appSubscription.remove(); + motionSubscription.remove(); + }; + }, []); + return focused && active && !reducedMotion; +} + +function useSetupClock(active: boolean) { + const focused = useIsFocused(); + const [now, setNow] = useState(Date.now); + const [appState, setAppState] = useState(AppState.currentState); + useEffect(() => { + const subscription = AppState.addEventListener("change", setAppState); + return () => subscription.remove(); + }, []); + useEffect(() => { + if (!active || appState !== "active" || !focused) return; + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, [active, appState, focused]); + return now; +} + +function SetupDetailsSheet({ + snapshot, + turnStarted, + onCancel, + onWorkLocally, + onClose, + now, +}: WorktreeSetupCardProps & { onClose: () => void; now: number }) { + const insets = useSafeAreaInsets(); + const motionActive = useSetupMotionActive(); + const [bodyHeight, setBodyHeight] = useState(0); + const canCancel = snapshot.phase === "running" && !turnStarted; + return ( + + setBodyHeight(height)} + contentContainerStyle={{ + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: Math.max(20, insets.bottom), + }} + > + {/* Agent startup is the header handoff, not a fifth setup step. */} + {snapshot.stages + .filter((stage) => stage.id !== "agent") + .map((stage) => ( + + + {stage.id === "setup-script" && + (stage.status === "running" || stage.status === "failed" || stage.tail.length > 0) ? ( + + ) : null} + + ))} + {snapshot.phase === "failed" && snapshot.error ? ( + + {snapshot.error} + + ) : null} + {canCancel ? ( + + { + onClose(); + onCancel(); + }} + className="min-h-11 justify-center px-2" + > + Cancel setup + + {onWorkLocally ? ( + { + onClose(); + onWorkLocally(); + }} + className="min-h-11 justify-center px-2" + > + Work locally + + ) : null} + + ) : null} + + + ); +} + +function StageRow({ + stage, + scriptName, + now, + compact = false, + animate = true, +}: { + stage: WorktreeSetupStage; + scriptName: string | null; + now: number; + compact?: boolean; + animate?: boolean; +}) { + const label = + stage.id === "setup-script" + ? (scriptName ?? worktreeSetupStageLabel(stage.id)) + : worktreeSetupStageLabel(stage.id); + const detail = + stage.status === "pending" + ? null + : stage.status === "skipped" + ? (stage.detail ?? "skipped") + : stage.id === "checkout" && stage.status === "running" && stage.percent !== null + ? `${stage.percent}%` + : stage.detail; + return ( + + + {stage.status === "running" && animate ? ( + + ) : ( + + )} + + {stage.status === "running" && animate ? ( + + ) : ( + + {label} + + )} + {detail ? ( + + {detail} + + ) : null} + {stage.status !== "pending" && stage.status !== "skipped" ? ( + + {elapsed(stage.startedAt, stage.endedAt, now)} + + ) : null} + + ); +} + +const OUTPUT_TAIL_SLOTS = [0, 1, 2, 3] as const; + +/** Fixed four-line output window, shown only in Details. */ +function OutputTail({ lines, failed }: { lines: ReadonlyArray; failed: boolean }) { + return ( + + {OUTPUT_TAIL_SLOTS.map((slot) => ( + + {lines[lines.length - OUTPUT_TAIL_SLOTS.length + slot] || "\u00a0"} + + ))} + + ); +} diff --git a/apps/mobile/src/features/threads/worktree-setup-sheet.android.tsx b/apps/mobile/src/features/threads/worktree-setup-sheet.android.tsx new file mode 100644 index 0000000000..267414a6ad --- /dev/null +++ b/apps/mobile/src/features/threads/worktree-setup-sheet.android.tsx @@ -0,0 +1,47 @@ +import { Host, ModalBottomSheet, RNHostView } from "@expo/ui/jetpack-compose"; +import { Pressable, View, useWindowDimensions } from "react-native"; +import { withUniwind } from "uniwind"; +import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; +import { AppText as Text } from "../../components/AppText"; +import type { WorktreeSetupSheetProps } from "./worktree-setup-sheet"; + +const NativeBottomSheet = withUniwind(ModalBottomSheet, { + containerColor: { fromClassName: "containerColorClassName", styleProperty: "accentColor" }, +}); + +export function WorktreeSetupSheet({ children, height, onClose }: WorktreeSetupSheetProps) { + const window = useWindowDimensions(); + return ( + + + + + + Done + + } + /> + {children} + + + + + ); +} diff --git a/apps/mobile/src/features/threads/worktree-setup-sheet.tsx b/apps/mobile/src/features/threads/worktree-setup-sheet.tsx new file mode 100644 index 0000000000..ca095c1058 --- /dev/null +++ b/apps/mobile/src/features/threads/worktree-setup-sheet.tsx @@ -0,0 +1,43 @@ +import { useState, type ReactElement } from "react"; +import { Modal, Pressable, View } from "react-native"; +import { AppText as Text } from "../../components/AppText"; +import { ContextSheetSize } from "../../components/ContextSheetSize"; + +export interface WorktreeSetupSheetProps { + children: ReactElement; + height: number; + onClose: () => void; +} + +export function WorktreeSetupSheet({ children, height, onClose }: WorktreeSetupSheetProps) { + const [headerHeight, setHeaderHeight] = useState(64); + return ( + + + + setHeaderHeight(event.nativeEvent.layout.height)} + className="flex-row items-center justify-between px-5 pt-3 pb-1" + > + + Worktree setup + + + Done + + + {children} + + + ); +} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 52a9920172..c4b5c951ef 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -957,6 +957,26 @@ describe("buildThreadFeed", () => { } }); + it("leaves failed setup snapshots to the setup card", () => { + const feed = buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-setup-failed"), + projectId: ProjectId.make("project-1"), + title: "Failed setup", + activities: [ + makeActivity({ + id: EventId.make("worktree-failed"), + kind: "worktree-setup", + summary: "Worktree setup failed", + createdAt: "2026-08-30T00:00:00.000Z", + tone: "error", + }), + ], + }), + ); + expect(feed).toEqual([]); + }); + it.each(["setup-script.requested", "setup-script.started"])( "keeps error-toned %s notices visible", (kind) => { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index f2449e8519..5539fa8213 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -420,7 +420,12 @@ function deriveWorkLogEntries( const ordered = Arr.sort(activities, activityOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of foldUserInputActivities(ordered)) { - if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; + // The setup card owns its snapshot, including failed and cancelled outcomes. + if ( + isWorktreeSetupActivity(activity.kind) && + (activity.tone !== "error" || activity.kind === "worktree-setup") + ) + continue; if (activity.kind === "tool.started") continue; // Like web: an agent's task.started row anchors its batch. It has a fixed // id and timestamp, unlike progress ticks, whose stable per-task id is diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index fe2b05d786..68cf9e9105 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -142,6 +142,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, + [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 59a3074462..13914c6bcc 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -79,6 +79,7 @@ describe("CheckpointDiffQuery.layer", () => { getDeliveredDelegationNotificationIds: () => Effect.succeed([]), getPendingRequestActivities: () => Effect.succeed([]), getUserInputActivity: () => Effect.die("unused"), + listActivitiesByKind: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), getSnapshot: () => @@ -197,6 +198,7 @@ describe("CheckpointDiffQuery.layer", () => { getDeliveredDelegationNotificationIds: () => Effect.succeed([]), getPendingRequestActivities: () => Effect.succeed([]), getUserInputActivity: () => Effect.die("unused"), + listActivitiesByKind: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), getSnapshot: () => @@ -290,6 +292,7 @@ describe("CheckpointDiffQuery.layer", () => { getDeliveredDelegationNotificationIds: () => Effect.succeed([]), getPendingRequestActivities: () => Effect.succeed([]), getUserInputActivity: () => Effect.die("unused"), + listActivitiesByKind: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), getSnapshot: () => @@ -368,6 +371,7 @@ describe("CheckpointDiffQuery.layer", () => { getDeliveredDelegationNotificationIds: () => Effect.succeed([]), getPendingRequestActivities: () => Effect.succeed([]), getUserInputActivity: () => Effect.die("unused"), + listActivitiesByKind: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), getSnapshot: () => @@ -431,6 +435,7 @@ describe("CheckpointDiffQuery.layer", () => { getDeliveredDelegationNotificationIds: () => Effect.succeed([]), getPendingRequestActivities: () => Effect.succeed([]), getUserInputActivity: () => Effect.die("unused"), + listActivitiesByKind: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), getSnapshot: () => diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 69b6431223..951003a271 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -242,6 +242,7 @@ export const make = Effect.gen(function* () { browserProfiles: true, projectDefaults: true, projectCloneTracking: true, + worktreeSetupTracking: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" || desktopAppUpdate ? { diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index b8052b4246..3eeab9761c 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -1,6 +1,8 @@ import { assert, describe, expect, it, vi } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { ProjectId, ThreadId, VcsRepositoryDetectionError } from "@t3tools/contracts"; @@ -28,6 +30,35 @@ function makeLayer(input: { } describe("GitWorkflowService", () => { + it.effect("reports a non-Git VCS repository as not a Git repository", () => + Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const isRepository = yield* workflow.isRepository("/jj-repo"); + + assert.equal(isRepository, false); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => + Effect.succeed({ + kind: "jj", + repository: { + kind: "jj", + rootPath: "/jj-repo", + metadataPath: "/jj-repo/.jj", + freshness: { + source: "live-local", + observedAt: DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"), + expiresAt: Option.none(), + }, + }, + driver: {} as VcsDriverRegistry.VcsDriverHandle["driver"], + }), + }), + ), + ), + ); + it.effect("returns an empty local status when no VCS repository is detected", () => Effect.gen(function* () { const workflow = yield* GitWorkflowService.GitWorkflowService; diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 849a7afe97..9f3176cee8 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -47,6 +47,11 @@ function canonicalWorkspacePath(cwd: string): string { export class GitWorkflowService extends Context.Service< GitWorkflowService, { + readonly isRepository: (cwd: string) => Effect.Effect; + readonly hasCommit: (input: { + readonly cwd: string; + readonly refName: string; + }) => Effect.Effect; readonly status: ( input: VcsStatusInput, ) => Effect.Effect; @@ -76,6 +81,7 @@ export class GitWorkflowService extends Context.Service< ) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, + options?: GitVcsDriver.CreateWorktreeOptions, ) => Effect.Effect; readonly fetchRemote: (input: { readonly cwd: string; @@ -313,6 +319,31 @@ export const make = Effect.gen(function* () { ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input))); return GitWorkflowService.of({ + isRepository: (cwd) => + registry.detect({ cwd }).pipe( + Effect.map((handle) => handle?.kind === "git"), + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "GitWorkflowService.isRepository", + cwd, + detail: "Failed to detect a VCS repository for this Git workflow.", + cause, + }), + ), + ), + hasCommit: (input) => + ensureGitCommand("GitWorkflowService.hasCommit", input.cwd).pipe( + Effect.andThen( + git.execute({ + operation: "GitWorkflowService.hasCommit", + cwd: input.cwd, + args: ["rev-parse", "--verify", `${input.refName}^{commit}`], + allowNonZeroExit: true, + }), + ), + Effect.map((result) => result.exitCode === 0), + ), status: (input) => detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe( Effect.flatMap((isGitRepository) => @@ -361,10 +392,10 @@ export const make = Effect.gen(function* () { isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()), ), ), - createWorktree: (input) => + createWorktree: (input, options) => ensureMutationCommand("GitWorkflowService.createWorktree", input.cwd).pipe( Effect.andThen(ensureGitCommand("GitWorkflowService.createWorktree", input.cwd)), - Effect.andThen(git.createWorktree(input)), + Effect.andThen(git.createWorktree(input, options)), ), fetchRemote: (input) => ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe( diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index e778d70456..761357b83a 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -752,8 +752,12 @@ export const make = Effect.gen(function* () { >, ) { if (event.type === "thread.message-sent") { + // A bootstrap message lands before the worktree exists; its baseline + // would snapshot the project checkout. The turn-start event that + // follows captures it against the right cwd. if ( event.metadata.historyImport === true || + event.metadata.deferredTurn === true || event.payload.role !== "user" || event.payload.streaming || event.payload.turnId !== null diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 0afbc208e2..694d47d20c 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -622,6 +622,7 @@ describe("OrchestrationEngine", () => { Layer.succeed(ProjectionSnapshotQuery, { getPendingRequestActivities: () => Effect.succeed([]), getUserInputActivity: () => Effect.die("unused"), + listActivitiesByKind: () => Effect.die("unused"), getCommandReadModel: () => Effect.succeed(commandReadModel), getSnapshot: () => Effect.sync(() => { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index f21693a1bd..75a4df48bc 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -3683,3 +3683,51 @@ it.effect("omits foreign-host PRs from legacy snapshots while preserving native } }).pipe(Effect.provide(layer)); }); + +projectionSnapshotLayer("ProjectionSnapshotQuery activities by kind", (it) => { + it.effect("lists one kind across active threads only, without hydrating the threads", () => + Effect.gen(function* () { + const query = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + const timestamp = "2026-03-02T00:00:00.000Z"; + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, scripts_json, created_at, updated_at + ) VALUES ('project-kinds', 'Project', '/tmp/project-kinds', '[]', ${timestamp}, ${timestamp}) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + created_at, updated_at, deleted_at + ) VALUES + ('thread-live', 'project-kinds', 'Live', '{"instanceId":"codex","model":"gpt-5"}', + 'full-access', 'default', ${timestamp}, ${timestamp}, NULL), + ('thread-gone', 'project-kinds', 'Gone', '{"instanceId":"codex","model":"gpt-5"}', + 'full-access', 'default', ${timestamp}, ${timestamp}, ${timestamp}), + ('thread-shelved', 'project-kinds', 'Shelved', '{"instanceId":"codex","model":"gpt-5"}', + 'full-access', 'default', ${timestamp}, ${timestamp}, NULL) + `; + yield* sql`UPDATE projection_threads SET archived_at = ${timestamp} WHERE thread_id = 'thread-shelved'`; + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, created_at + ) VALUES + ('setup-live', 'thread-live', NULL, 'info', 'worktree-setup', 'Setting up', + '{"phase":"running"}', ${timestamp}), + ('other-live', 'thread-live', NULL, 'info', 'tool.completed', 'Other', + '{}', ${timestamp}), + ('setup-gone', 'thread-gone', NULL, 'info', 'worktree-setup', 'Setting up', + '{"phase":"running"}', ${timestamp}), + ('setup-shelved', 'thread-shelved', NULL, 'info', 'worktree-setup', 'Setting up', + '{"phase":"running"}', ${timestamp}) + `; + + const setups = yield* query.listActivitiesByKind("worktree-setup"); + assert.deepEqual( + setups.map((activity) => [activity.id, activity.kind, activity.payload]), + [["setup-live", "worktree-setup", { phase: "running" }]], + ); + assert.deepEqual(yield* query.listActivitiesByKind("nope"), []); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 869dbfc816..fb9ccdd9f4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1817,6 +1817,40 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ); + const listActivityRowsByKind = SqlSchema.findAll({ + Request: Schema.Struct({ kind: Schema.String }), + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ kind }) => sql` + SELECT + a.activity_id AS "activityId", + a.thread_id AS "threadId", + a.turn_id AS "turnId", + a.tone, + a.kind, + a.summary, + a.payload_json AS "payload", + a.sequence, + a.created_at AS "createdAt" + FROM projection_thread_activities a + JOIN projection_threads t ON t.thread_id = a.thread_id + WHERE a.kind = ${kind} + AND t.deleted_at IS NULL + AND t.archived_at IS NULL + ORDER BY a.created_at ASC, a.activity_id ASC + `, + }); + + const listActivitiesByKind: ProjectionSnapshotQueryShape["listActivitiesByKind"] = (kind) => + listActivityRowsByKind({ kind }).pipe( + Effect.map((rows) => rows.map(mapThreadActivityRow)), + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.listActivitiesByKind:query", + "ProjectionSnapshotQuery.listActivitiesByKind:decodeRow", + ), + ), + ); + const listThreadActivityIdsByThread = SqlSchema.findAll({ Request: ThreadIdLookupInput, Result: ProjectionThreadActivityIdRowSchema, @@ -4235,6 +4269,7 @@ pending_approval_requests AS ( getDelegationObservationActivities, getUserInputActivity, getPendingRequestActivities, + listActivitiesByKind, getSnapshot, getShellSnapshot, getArchivedShellSnapshot, diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 3a931785b8..41ea92a0cb 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -103,6 +103,15 @@ export interface ProjectionSnapshotQueryShape { readonly requestId: ApprovalRequestId; }) => Effect.Effect, ProjectionRepositoryError>; + /** + * Read every activity of one kind across active (not deleted, not archived) + * threads, without hydrating the threads. Used at startup to find state a + * crashed process left behind. + */ + readonly listActivitiesByKind: ( + kind: string, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Read the lightweight command snapshot used to bootstrap the in-memory * orchestration engine without hydrating message/activity/checkpoint bodies. diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 124498d43d..0493d45d5d 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1702,27 +1702,39 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" const admissionDeadlineAt = DateTime.formatIso( DateTime.add(admissionObservedAt, { milliseconds: 60_000 }), ); - const userMessageEvent: Omit = { - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt: command.createdAt, - commandId: command.commandId, - })), - type: "thread.message-sent", - payload: { - threadId: command.threadId, - messageId: command.message.messageId, - role: "user", - text: command.message.text, - attachments: command.message.attachments, - ...(command.message.context !== undefined ? { context: command.message.context } : {}), - turnId: null, - streaming: false, - createdAt: command.createdAt, - updatedAt: command.createdAt, - }, - }; + // A worktree bootstrap persists the message ahead of the turn with + // `thread.message.user.append`; the turn then only references it. + const persistedUserMessage = targetThread.messages.find( + (message) => + message.id === command.message.messageId && + message.role === "user" && + message.turnId === null, + ); + const userMessageEvent: Omit | null = persistedUserMessage + ? null + : { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: command.message.messageId, + role: "user", + text: command.message.text, + attachments: command.message.attachments, + ...(command.message.context !== undefined + ? { context: command.message.context } + : {}), + turnId: null, + streaming: false, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; // Steering needs an admitted turn to steer into. A session can sit in // "running" with no active turn (Claude reports system/status between // turns) or with a turn the provider opened on its own (Claude continuing @@ -1757,7 +1769,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" occurredAt: command.createdAt, commandId: command.commandId, })), - causationEventId: userMessageEvent.eventId, + ...(userMessageEvent ? { causationEventId: userMessageEvent.eventId } : {}), type: "thread.turn-start-requested", payload: { threadId: command.threadId, @@ -1823,7 +1835,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } return [ ...lifecycleResetEvents, - userMessageEvent, + ...(userMessageEvent ? [userMessageEvent] : []), ...admissionPendingEvents, turnStartRequestedEvent, ]; @@ -2204,6 +2216,48 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" return [...lifecycleResetEvents, userMessageEvent, requestedEvent]; } + case "thread.message.user.append": { + if (isImportedAgentSessionMessageId(command.message.messageId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message id '${command.message.messageId}' uses the reserved imported-session namespace.`, + }); + } + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + if (thread.messages.some((message) => message.id === command.message.messageId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message '${command.message.messageId}' already exists on thread '${command.threadId}'.`, + }); + } + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + metadata: { deferredTurn: true }, + })), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: command.message.messageId, + role: "user", + text: command.message.text, + attachments: command.message.attachments, + ...(command.message.context !== undefined ? { context: command.message.context } : {}), + turnId: null, + streaming: false, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + } + case "thread.turn.interrupt": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/decider.userMessageAppend.test.ts b/apps/server/src/orchestration/decider.userMessageAppend.test.ts new file mode 100644 index 0000000000..278a07b03d --- /dev/null +++ b/apps/server/src/orchestration/decider.userMessageAppend.test.ts @@ -0,0 +1,143 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const createdAt = "2026-08-24T10:00:00.000Z"; +const projectId = ProjectId.make("project-1"); +const threadId = ThreadId.make("thread-bootstrap"); +const messageId = MessageId.make("message-bootstrap"); + +const readModelWithThread = Effect.gen(function* () { + const withProject = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-project-created"), + aggregateKind: "project", + aggregateId: projectId, + type: "project.created", + occurredAt: createdAt, + commandId: CommandId.make("command-project-created"), + causationEventId: null, + correlationId: CommandId.make("command-project-created"), + metadata: {}, + payload: { + projectId, + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: null, + scripts: [], + createdAt, + updatedAt: createdAt, + }, + }); + return yield* projectEvent(withProject, { + sequence: 2, + eventId: EventId.make("event-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-thread-created"), + metadata: {}, + payload: { + threadId, + projectId, + title: "Bootstrap thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); +}); + +const appendCommand = { + type: "thread.message.user.append" as const, + commandId: CommandId.make("command-append"), + threadId, + message: { messageId, text: "Build it", attachments: [] }, + createdAt, +}; + +const turnStartCommand = { + type: "thread.turn.start" as const, + commandId: CommandId.make("command-turn-start"), + threadId, + message: { messageId, role: "user" as const, text: "Build it", attachments: [] }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + createdAt, +}; + +it.layer(NodeServices.layer)("thread.message.user.append", (it) => { + it.effect("persists a user message without a turn, tagged as deferred", () => + Effect.gen(function* () { + const readModel = yield* readModelWithThread; + const planned = yield* decideOrchestrationCommand({ command: appendCommand, readModel }); + const events = Array.isArray(planned) ? planned : [planned]; + expect(events.map((event) => event.type)).toEqual(["thread.message-sent"]); + expect(events[0]?.metadata.deferredTurn).toBe(true); + expect(events[0]?.payload).toMatchObject({ messageId, role: "user", turnId: null }); + }), + ); + + it.effect("rejects a message id that already exists on the thread", () => + Effect.gen(function* () { + const readModel = yield* readModelWithThread; + const first = yield* decideOrchestrationCommand({ command: appendCommand, readModel }); + const firstEvent = Array.isArray(first) ? first[0]! : first; + const withMessage = yield* projectEvent(readModel, { ...firstEvent, sequence: 3 }); + const error = yield* Effect.flip( + decideOrchestrationCommand({ command: appendCommand, readModel: withMessage }), + ); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("already exists"); + }), + ); + + it.effect("lets the following turn start reference the message instead of re-sending it", () => + Effect.gen(function* () { + const readModel = yield* readModelWithThread; + const appended = yield* decideOrchestrationCommand({ command: appendCommand, readModel }); + const appendedEvent = Array.isArray(appended) ? appended[0]! : appended; + const withMessage = yield* projectEvent(readModel, { ...appendedEvent, sequence: 3 }); + + const planned = yield* decideOrchestrationCommand({ + command: turnStartCommand, + readModel: withMessage, + }); + const events = Array.isArray(planned) ? planned : [planned]; + expect(events.map((event) => event.type)).toEqual([ + "thread.session-set", + "thread.turn-start-requested", + ]); + expect( + events.find((event) => event.type === "thread.turn-start-requested")?.payload, + ).toMatchObject({ messageId, admissionIntent: { kind: "start" } }); + + // Without the append the turn start still carries the message itself. + const direct = yield* decideOrchestrationCommand({ command: turnStartCommand, readModel }); + const directEvents = Array.isArray(direct) ? direct : [direct]; + expect(directEvents.map((event) => event.type)).toEqual([ + "thread.message-sent", + "thread.session-set", + "thread.turn-start-requested", + ]); + }), + ); +}); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 7015b1ac4c..796c5b4774 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -1173,4 +1173,70 @@ describe("orchestration projector", () => { expect(thread?.checkpoints[0]?.turnId).toBe("turn-100"); expect(thread?.checkpoints.at(-1)?.turnId).toBe("turn-599"); }); + + effectIt.effect("keeps the worktree setup record past the activity retention cap", () => + Effect.gen(function* () { + const createdAt = "2026-03-01T10:00:00.000Z"; + const threadId = "thread-setup-retained"; + const afterCreate = yield* projectEvent( + createEmptyReadModel(createdAt), + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: "cmd-create-setup-retained", + payload: { + threadId, + projectId: "project-1", + title: "setup retained", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ); + const activityEvent = (sequence: number, id: string, kind: string) => + makeEvent({ + sequence, + type: "thread.activity-appended", + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: `2026-03-01T10:${String(Math.floor(sequence / 60) % 60).padStart(2, "0")}:${String(sequence % 60).padStart(2, "0")}.000Z`, + commandId: `cmd-activity-${sequence}`, + payload: { + threadId, + activity: { + id, + tone: "info", + kind, + summary: kind, + payload: {}, + turnId: null, + createdAt: `2026-03-01T10:${String(Math.floor(sequence / 60) % 60).padStart(2, "0")}:${String(sequence % 60).padStart(2, "0")}.000Z`, + }, + }, + }); + let model = yield* projectEvent( + afterCreate, + activityEvent(2, `worktree-setup:${threadId}`, "worktree-setup"), + ); + for (let index = 0; index < 600; index += 1) { + model = yield* projectEvent( + model, + activityEvent(3 + index, `tool-${index}`, "tool.completed"), + ); + } + const thread = model.threads.find((entry) => entry.id === threadId); + expect(thread?.activities).toHaveLength(501); + expect(thread?.activities[0]?.id).toBe(`worktree-setup:${threadId}`); + }), + ); }); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 49684674bc..656f7758c2 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -13,6 +13,7 @@ import { OrchestrationMessage, OrchestrationSession, OrchestrationThread, + WORKTREE_SETUP_ACTIVITY_KIND, } from "@t3tools/contracts"; import { legacyLinkedPullRequestOf, @@ -76,7 +77,13 @@ function retainThreadActivities(activities: OrchestrationThread["activities"]) { } const pendingActivities = new Set(pending.values()); return activities.filter( - (activity, index) => index >= recentStart || pendingActivities.has(activity), + (activity, index) => + index >= recentStart || + pendingActivities.has(activity) || + // The worktree setup record is upserted under one id for the thread's + // whole life and is the only durable copy of a running setup; an async + // setup script can outlast a chatty first turn. + activity.kind === WORKTREE_SETUP_ACTIVITY_KIND, ); } diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index f219b44a3b..e9018859c2 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -42,6 +42,7 @@ const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray< getDeliveredDelegationNotificationIds: () => Effect.succeed([]), getPendingRequestActivities: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), + listActivitiesByKind: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.succeed({ diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 26d7f6894f..b1e330ff2a 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -632,6 +632,12 @@ export const make = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const baseDir = path.resolve(serverConfig.baseDir); const worktreesDir = path.resolve(serverConfig.worktreesDir); + // Candidate paths are canonicalized below; compare the managed roots in + // both forms as well (macOS aliases /var to /private/var). + const realBaseDir = yield* fileSystem.realPath(baseDir).pipe(Effect.orElseSucceed(() => baseDir)); + const realWorktreesDir = yield* fileSystem + .realPath(worktreesDir) + .pipe(Effect.orElseSucceed(() => worktreesDir)); // Windows filesystems are case-insensitive, so path prefix checks there // must case fold. const foldWorktreeCase = (yield* HostProcessPlatform) === "win32"; @@ -661,7 +667,11 @@ export const make = Effect.gen(function* () { normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( normalizeForWorktreeMatch(baseDir, foldWorktreeCase), ) || - isT3ManagedWorktree(candidatePath, worktreesDir, foldWorktreeCase); + normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( + normalizeForWorktreeMatch(realBaseDir, foldWorktreeCase), + ) || + isT3ManagedWorktree(candidatePath, worktreesDir, foldWorktreeCase) || + isT3ManagedWorktree(candidatePath, realWorktreesDir, foldWorktreeCase); const listDirectory = (directory: string) => fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index ca6ddc83fe..7b02671b73 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "@effect/vitest"; -import { type OrchestrationProject, ProjectId } from "@t3tools/contracts"; +import { type OrchestrationProject, ProjectId, type TerminalEvent } from "@t3tools/contracts"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -31,6 +32,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getDeliveredDelegationNotificationIds: () => Effect.succeed([]), getPendingRequestActivities: () => Effect.succeed([]), getUserInputActivity: () => Effect.die("unused"), + listActivitiesByKind: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), @@ -57,11 +59,11 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => searchThreads: () => Effect.succeed({ matches: [] }), }); -const makeTerminalManagerLayer = ( - overrides: Pick, -) => +type TerminalOverrides = Pick & + Partial>; + +const makeTerminalManagerLayer = (overrides: TerminalOverrides) => Layer.succeed(TerminalManager.TerminalManager, { - ...overrides, attachStream: () => Effect.die(new Error("unused")), resize: () => Effect.void, clear: () => Effect.void, @@ -69,11 +71,12 @@ const makeTerminalManagerLayer = ( close: () => Effect.void, subscribe: () => Effect.succeed(() => undefined), subscribeMetadata: () => Effect.succeed(() => undefined), + ...overrides, }); const testLayer = ( project: OrchestrationProject, - terminal: Pick, + terminal: TerminalOverrides, settings = ServerSettings.layerTest(), ) => ProjectSetupScriptRunner.layer.pipe( @@ -113,7 +116,12 @@ describe("ProjectSetupScriptRunner", () => { terminalId: "setup-default-setup", cwd: "/repo/worktrees/a", worktreePath: "/repo/worktrees/a", - env: { T3CODE_PROJECT_ROOT: "/repo/project", T3CODE_WORKTREE_PATH: "/repo/worktrees/a" }, + env: { + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + NO_COLOR: "1", + FORCE_COLOR: "0", + }, }); expect(write).toHaveBeenCalledWith({ threadId: "thread-1", @@ -201,8 +209,10 @@ describe("ProjectSetupScriptRunner", () => { status: "started", scriptId: "setup", scriptName: "Setup", + scriptCommand: "bun install", terminalId: "setup-setup", cwd: "/repo/worktrees/a", + async: true, }); expect(open).toHaveBeenCalledWith({ threadId: "thread-1", @@ -210,6 +220,8 @@ describe("ProjectSetupScriptRunner", () => { cwd: "/repo/worktrees/a", worktreePath: "/repo/worktrees/a", env: { + NO_COLOR: "1", + FORCE_COLOR: "0", T3CODE_PROJECT_ROOT: "/repo/project", T3CODE_WORKTREE_PATH: "/repo/worktrees/a", }, @@ -223,6 +235,214 @@ describe("ProjectSetupScriptRunner", () => { }, ); + it.effect( + "wraps the command with a completion sentinel and resolves the exit code from terminal output", + () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const writes: string[] = []; + const write = vi.fn((input: { data: string }) => + Effect.sync(() => void writes.push(input.data)), + ); + let listener: ((event: TerminalEvent) => Effect.Effect) | null = null; + const subscribe = vi.fn((next: (event: TerminalEvent) => Effect.Effect) => { + listener = next; + return Effect.succeed(() => { + listener = null; + }); + }); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + const emit = (data: string) => + Effect.suspend(() => + listener + ? listener({ threadId: "thread-1", terminalId: "setup-setup", type: "output", data }) + : Effect.void, + ); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const seen: string[] = []; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + observeCompletion: { + onOutputLine: (line) => Effect.sync(() => void seen.push(line)), + }, + }); + expect(result.status).toBe("started"); + if (result.status !== "started") return; + expect(result.completion).toBeDefined(); + + // The subscription is attached before the command is written. + expect(subscribe).toHaveBeenCalledTimes(1); + expect(writes).toHaveLength(1); + // The block closes on its own line so a trailing comment in the + // command cannot swallow the sentinel, and the sentinel carries a + // per-run token so script output cannot spoof it. + const written = writes[0] ?? ""; + const sentinel = /__T3_SETUP_DONE___[0-9a-f]{32}:/.exec(written)?.[0]; + expect(sentinel).toBeDefined(); + expect(written).toBe(`( bun install\r); printf '\\n${sentinel}%s\\n' "$?"\r`); + + // Output arrives in chunks; partial lines are buffered until a newline, + // control sequences are stripped, and the echoed wrapper is hidden. + yield* emit(`( bun install\r\n> ); printf '\\n${sentinel}%s\\n' "$?"\r\n`); + yield* emit("\u001b[32mResolving"); + yield* emit(" deps\u001b[0m\r\n"); + // Progress redraws separated by bare carriage returns are their own lines. + yield* emit("Progress: 1/3\rProgress: 2/3\rProgress: 3/3\r\nDone in 2s\r\n"); + // A spoofed sentinel from the script itself must not settle completion. + yield* emit("__T3_SETUP_DONE__:0\r\n"); + yield* emit(`__T3_SETUP_DONE___${"0".repeat(32)}:0\r\n`); + yield* emit(`${sentinel}3\r\n`); + + const completion = yield* result.completion!; + expect(completion.exitCode).toBe(3); + expect(seen).toEqual([ + "Resolving deps", + "Progress: 1/3", + "Progress: 2/3", + "Progress: 3/3", + "Done in 2s", + "__T3_SETUP_DONE__:0", + `__T3_SETUP_DONE___${"0".repeat(32)}:0`, + ]); + // The subscription is torn down once the sentinel arrives. + expect(listener).toBeNull(); + }).pipe( + Effect.provide(testLayer(project, { open, write, subscribe })), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }), + ); + }, + ); + + it.effect("unsubscribes from terminal output when the command cannot be written", () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const write = vi.fn(() => + Effect.fail( + new TerminalManager.TerminalCwdStatError({ cwd: "/repo/worktrees/a", cause: {} }), + ), + ); + const unsubscribe = vi.fn(); + const subscribe = vi.fn(() => Effect.succeed(unsubscribe)); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner + .runForThread({ + threadId: "thread-1", + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + observeCompletion: {}, + }) + .pipe(Effect.result); + expect(result._tag).toBe("Failure"); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }).pipe(Effect.provide(testLayer(project, { open, write, subscribe }))); + }); + + it.effect.each([ + { + shell: "/usr/bin/fish", + expected: + /^begin\rbun install\rend; printf '\\n__T3_SETUP_DONE___[0-9a-f]{32}:%s\\n' \$status\r$/, + }, + { + shell: "/bin/bash", + expected: /^\( bun install\r\); printf '\\n__T3_SETUP_DONE___[0-9a-f]{32}:%s\\n' "\$\?"\r$/, + }, + ])("wraps the command for the $shell syntax", ({ shell, expected }) => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const writes: string[] = []; + const write = vi.fn((input: { data: string }) => + Effect.sync(() => void writes.push(input.data)), + ); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + yield* runner.runForThread({ + threadId: "thread-1", + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + observeCompletion: {}, + }); + expect(writes).toHaveLength(1); + expect(writes[0]).toMatch(expected); + }).pipe( + Effect.provide(testLayer(project, { open, write })), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, { SHELL: shell }), + ); + }); + it.effect("keeps terminal failures as the exact cause of a structured operation error", () => { const rootCause = new Error("stat failed"); const terminalError = new TerminalManager.TerminalCwdStatError({ diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 3bbb0daa79..16cbfaa594 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,10 +1,15 @@ import { ProjectId } from "@t3tools/contracts"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { projectScriptRuntimeEnv, resolveProjectScripts, setupProjectScript, } from "@t3tools/shared/projectScripts"; +import * as NodeCrypto from "node:crypto"; + +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -22,8 +27,26 @@ export interface ProjectSetupScriptRunnerResultStarted { readonly status: "started"; readonly scriptId: string; readonly scriptName: string; + readonly scriptCommand: string; readonly terminalId: string; readonly cwd: string; + /** False when the script's `async` flag asks the agent to wait for it. */ + readonly async: boolean; + /** + * Resolves when the script's shell prints the completion sentinel. The + * exit code is null when the terminal exited or was closed before the + * sentinel arrived. Only present when `observeCompletion` was requested. + */ + readonly completion?: Effect.Effect; +} + +export interface ProjectSetupScriptCompletion { + readonly exitCode: number | null; + readonly durationMs: number; +} + +export interface ProjectSetupScriptOutputLine { + readonly line: string; } export type ProjectSetupScriptRunnerResult = @@ -36,6 +59,14 @@ export interface ProjectSetupScriptRunnerInput { readonly projectCwd?: string; readonly worktreePath: string; readonly preferredTerminalId?: string; + /** + * Wrap the command so the shell reports its exit code back through the + * terminal stream, and forward cleaned output lines while it runs. The + * bootstrap flow uses this to drive the worktree setup card. + */ + readonly observeCompletion?: { + readonly onOutputLine?: (line: string) => Effect.Effect; + }; } export class ProjectSetupScriptOperationError extends Schema.TaggedError()( @@ -83,11 +114,176 @@ export class ProjectSetupScriptRunner extends Context.Service< } >()("t3/project/ProjectSetupScriptRunner") {} +/** @public Service construction is part of the canonical Effect module API. */ +/** + * Marker the wrapped setup command echoes so the exit code can be read from + * the PTY stream. Each run gets its own random token so script output cannot + * spoof completion, and the sentinel pattern is built per run from it. + */ +const COMPLETION_SENTINEL_PREFIX = "__T3_SETUP_DONE__"; +const OUTPUT_LINE_MAX_LENGTH = 400; +/** A partial line longer than this is a byte stream, not a line. Keep only the tail. */ +const PARTIAL_LINE_MAX_LENGTH = 4_096; + +function completionSentinel(token: string): string { + return `${COMPLETION_SENTINEL_PREFIX}_${token}:`; +} + +function completionSentinelPattern(token: string): RegExp { + return new RegExp(`${COMPLETION_SENTINEL_PREFIX}_${token}:(-?\\d+)`); +} + +/** Removes ANSI escape sequences and cursor controls so lines can be shown as plain text. */ +function stripTerminalControl(text: string): string { + return ( + text + .replace( + // eslint-disable-next-line no-control-regex + /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Za-z0-9]|\x1b[=>]/g, + "", + ) + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") + ); +} + +type CompletionShell = "posix" | "fish" | "powershell"; + +/** + * Predicts the shell TerminalManager will spawn for the setup terminal. The + * manager takes `$SHELL` on POSIX and PowerShell on Windows, falling back to + * other shells only when that one fails to spawn. + */ +function resolveCompletionShell( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): CompletionShell { + if (platform === "win32") return "powershell"; + const shell = env.SHELL ?? ""; + const name = shell.split("/").at(-1) ?? shell; + if (name === "fish") return "fish"; + if (name === "pwsh" || name === "powershell") return "powershell"; + return "posix"; +} + +/** + * Builds the shell input for the setup script. The command runs inside a + * block and the block closes on its own line, so a trailing `# comment` or a + * heredoc terminator in the command cannot swallow the sentinel. The shell + * reads the whole block before running any of it, so a script that reads + * stdin cannot consume the sentinel line either. Lines are separated by `\r` + * because that is the Enter key for every shell's line editor. + */ +function wrapCommandForCompletion( + command: string, + shell: CompletionShell, + sentinel: string, +): string { + const body = command.replace(/\r?\n/g, "\r"); + switch (shell) { + case "powershell": + return `$global:LASTEXITCODE = $null; & {\r${body}\r}; if ($null -ne $LASTEXITCODE) { $__t3c = $LASTEXITCODE } elseif ($?) { $__t3c = 0 } else { $__t3c = 1 }; Write-Host "${sentinel}$__t3c"`; + case "fish": + return `begin\r${body}\rend; printf '\\n${sentinel}%s\\n' $status`; + case "posix": + return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`; + } +} + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const terminalManager = yield* TerminalManager.TerminalManager; const serverSettings = yield* ServerSettings.ServerSettingsService; + const completionShell = resolveCompletionShell( + yield* HostProcessPlatform, + yield* HostProcessEnvironment, + ); + + /** + * Watches the setup terminal for the completion sentinel. Terminal output is + * a byte stream, so partial lines are buffered until a newline. The + * subscription is torn down once the sentinel, an exit, or a close arrives. + */ + const observeTerminalCompletion = (input: { + readonly threadId: string; + readonly terminalId: string; + /** Per-run sentinel, so only this run's wrapper can settle completion. */ + readonly sentinel: string; + readonly sentinelPattern: RegExp; + /** The shell echoes typed input; lines ending with these are the wrapper, not output. */ + readonly echoedWrapperLines: ReadonlyArray; + readonly onOutputLine: ((line: string) => Effect.Effect) | undefined; + }) => + Effect.gen(function* () { + const startedAtMs = yield* Clock.currentTimeMillis; + const done = yield* Deferred.make(); + let lineBuffer = ""; + let settled = false; + + const settle = (exitCode: number | null) => + Effect.suspend(() => { + if (settled) return Effect.void; + settled = true; + return Clock.currentTimeMillis.pipe( + Effect.flatMap((nowMs) => + Deferred.succeed(done, { exitCode, durationMs: nowMs - startedAtMs }), + ), + Effect.asVoid, + ); + }); + + const handleLine = (rawLine: string) => + Effect.suspend(() => { + const sentinel = input.sentinelPattern.exec(rawLine); + if (sentinel) { + const parsed = Number(sentinel[1]); + return settle(Number.isFinite(parsed) ? parsed : null); + } + const cleaned = stripTerminalControl(rawLine).trimEnd(); + if ( + cleaned.length === 0 || + cleaned.includes(input.sentinel) || + input.echoedWrapperLines.some((echoed) => cleaned.endsWith(echoed)) || + input.onOutputLine === undefined + ) { + return Effect.void; + } + return input.onOutputLine(cleaned.slice(0, OUTPUT_LINE_MAX_LENGTH)); + }); + + const unsubscribe = yield* terminalManager.subscribe((event) => { + if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { + return Effect.void; + } + if (event.type === "output") { + lineBuffer += event.data; + // A bare carriage return is how installers redraw a progress line in + // place; each redraw becomes a short line of its own instead of + // being glued into one long one. The wrapper echo is filtered per + // segment too, which is why `echoedWrapperLines` is split on the + // same `\r`: a line editor repainting the typed command yields the + // same segments. + const lines = lineBuffer.split(/\r\n|\r|\n/); + lineBuffer = lines.pop() ?? ""; + // A script that never prints a newline must not grow this forever. + // The sentinel is always on its own line, so keeping the tail is safe. + if (lineBuffer.length > PARTIAL_LINE_MAX_LENGTH) { + lineBuffer = lineBuffer.slice(-PARTIAL_LINE_MAX_LENGTH); + } + return Effect.forEach(lines, handleLine, { discard: true }); + } + if (event.type === "exited" || event.type === "closed") { + return settle(null); + } + return Effect.void; + }); + + const completion = Deferred.await(done).pipe( + Effect.ensuring(Effect.sync(() => unsubscribe())), + ); + return { completion, unsubscribe }; + }); const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn( "ProjectSetupScriptRunner.runForThread", @@ -154,6 +350,16 @@ export const make = Effect.gen(function* () { project: { cwd: project.workspaceRoot }, worktreePath: input.worktreePath, }); + const observe = input.observeCompletion; + const completionToken = observe ? NodeCrypto.randomUUID().replaceAll("-", "") : null; + const commandLine = + observe && completionToken + ? wrapCommandForCompletion( + script.command, + completionShell, + completionSentinel(completionToken), + ) + : script.command; yield* terminalManager .open({ @@ -161,7 +367,8 @@ export const make = Effect.gen(function* () { terminalId, cwd, worktreePath: input.worktreePath, - env, + // Setup may run before a terminal client attaches to answer color probes. + env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" }, }) .pipe( Effect.mapError( @@ -173,11 +380,24 @@ export const make = Effect.gen(function* () { }), ), ); + // Subscribe before writing so the sentinel cannot race past the listener. + const observed = + observe && completionToken + ? yield* observeTerminalCompletion({ + threadId: input.threadId, + terminalId, + sentinel: completionSentinel(completionToken), + sentinelPattern: completionSentinelPattern(completionToken), + echoedWrapperLines: commandLine.split("\r").filter((line) => line.length > 0), + onOutputLine: observe.onOutputLine, + }) + : undefined; + yield* terminalManager .write({ threadId: input.threadId, terminalId, - data: `${script.command}\r`, + data: `${commandLine}\r`, }) .pipe( Effect.mapError( @@ -188,14 +408,19 @@ export const make = Effect.gen(function* () { cause, }), ), + // Nothing will ever settle the completion if the command never ran. + Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())), ); return { status: "started", scriptId: script.id, scriptName: script.name, + scriptCommand: script.command, terminalId, cwd, + async: script.async !== false, + ...(observed ? { completion: observed.completion } : {}), } as const; }); diff --git a/apps/server/src/project/WorktreeSetupTracker.test.ts b/apps/server/src/project/WorktreeSetupTracker.test.ts new file mode 100644 index 0000000000..16e7b6aa64 --- /dev/null +++ b/apps/server/src/project/WorktreeSetupTracker.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ThreadId, WorktreeSetupSnapshot } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as WorktreeSetupTracker from "./WorktreeSetupTracker.ts"; + +const threadId = ThreadId.make("thread-1"); + +describe("WorktreeSetupTracker", () => { + it.effect("records stage transitions, checkout progress, and the final phase", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + yield* tracker.begin({ + threadId, + branch: "feature", + baseRef: "main", + stages: ["checkout", "fetch", "agent"], + fiber: null, + }); + + const initial = yield* tracker.get(threadId); + // Stages are reordered into the canonical setup order. + expect(initial?.stages.map((stage) => stage.id)).toEqual(["fetch", "checkout", "agent"]); + expect(initial?.phase).toBe("running"); + + yield* tracker.stageStatus(threadId, "fetch", "running"); + yield* tracker.stageStatus(threadId, "fetch", "done", "origin/main at abc1234"); + yield* tracker.stageStatus(threadId, "checkout", "running"); + yield* tracker.stage(threadId, "checkout", { percent: 42, detail: "42 / 100 files" }); + yield* tracker.finish(threadId, "failed", "boom"); + + const final = yield* tracker.get(threadId); + expect(final?.phase).toBe("failed"); + expect(final?.error).toBe("boom"); + const [fetch, checkout, agent] = final?.stages ?? []; + expect(fetch).toMatchObject({ status: "done", detail: "origin/main at abc1234" }); + expect(fetch?.startedAt).not.toBeNull(); + expect(fetch?.endedAt).not.toBeNull(); + // A stage still running when the setup fails is marked failed. + expect(checkout).toMatchObject({ status: "failed", percent: 42 }); + expect(agent?.status).toBe("pending"); + expect(final?.sequence).toBeGreaterThan(initial?.sequence ?? 0); + }), + ); + + it.effect("stream emits the current snapshot first and then only newer ones", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + yield* tracker.begin({ + threadId, + branch: null, + baseRef: null, + stages: ["agent"], + fiber: null, + }); + + const collected = yield* tracker.stream(threadId).pipe( + Stream.takeUntil((snapshot) => snapshot?.sequence === 2), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* tracker.stageStatus(threadId, "agent", "running"); + yield* tracker.appendTail(threadId, "agent", "line 1"); + + const snapshots = yield* Fiber.join(collected); + const sequences = snapshots.map((snapshot) => snapshot?.sequence ?? -1); + expect(sequences.at(-1)).toBe(2); + // Delivery is latest-value per subscriber, so intermediates may be + // skipped but never delivered out of order. + expect(sequences).toEqual([...sequences].toSorted((a, b) => a - b)); + expect(snapshots.at(-1)?.stages[0]?.tail).toEqual(["line 1"]); + }), + ); + + it.effect("stream never steps back behind the snapshot it started from", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + yield* tracker.begin({ + threadId, + branch: null, + baseRef: null, + stages: ["agent"], + fiber: null, + }); + yield* tracker.stageStatus(threadId, "agent", "running"); + yield* tracker.stageStatus(threadId, "agent", "done"); + + // A late subscriber starts at sequence 2 and must never see 0 or 1. + const collected = yield* tracker.stream(threadId).pipe( + Stream.takeUntil((snapshot) => snapshot?.phase === "done"), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* tracker.finish(threadId, "done"); + + const snapshots = yield* Fiber.join(collected); + expect(snapshots.length).toBeGreaterThan(0); + expect(snapshots.every((snapshot) => (snapshot?.sequence ?? -1) >= 2)).toBe(true); + expect(snapshots.at(-1)?.phase).toBe("done"); + }), + ); + + it.effect("a new setup on the same thread keeps sequences increasing", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + yield* tracker.begin({ + threadId, + branch: "first", + baseRef: null, + stages: ["agent"], + fiber: null, + }); + yield* tracker.finish(threadId, "failed", "boom"); + const failedSequence = (yield* tracker.get(threadId))?.sequence ?? -1; + + // A stream opened on the failed setup must still receive the next one. + const collected = yield* tracker.stream(threadId).pipe( + Stream.takeUntil((snapshot) => snapshot?.branch === "second"), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* tracker.begin({ + threadId, + branch: "second", + baseRef: null, + stages: ["agent"], + fiber: null, + }); + + const snapshots = yield* Fiber.join(collected); + const last = snapshots.at(-1); + expect(last?.phase).toBe("running"); + expect(last?.sequence).toBeGreaterThan(failedSequence); + }), + ); + + it.effect("finished setups are dropped after the retention window", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + yield* tracker.begin({ + threadId, + branch: null, + baseRef: null, + stages: ["agent"], + fiber: null, + }); + yield* tracker.finish(threadId, "done"); + expect((yield* tracker.get(threadId))?.phase).toBe("done"); + + yield* TestClock.adjust(Duration.seconds(31)); + expect(yield* tracker.get(threadId)).toBeNull(); + }), + ); + + it.effect("cancel interrupts the bootstrap fiber and reports whether one was running", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + const started = yield* Deferred.make(); + const fiber = yield* Deferred.succeed(started, undefined).pipe( + Effect.andThen(Effect.never), + Effect.forkChild, + ); + yield* Deferred.await(started); + yield* tracker.begin({ threadId, branch: null, baseRef: null, stages: ["agent"], fiber }); + + expect(yield* tracker.cancel(threadId)).toBe(true); + // cancel returns only after the bootstrap fiber has unwound. + const exit = yield* Fiber.await(fiber); + expect(Exit.hasInterrupts(exit)).toBe(true); + + yield* tracker.finish(threadId, "cancelled"); + expect(yield* tracker.cancel(threadId)).toBe(false); + expect(yield* tracker.cancel(ThreadId.make("unknown"))).toBe(false); + }), + ); + + it.effect("markUncancellable makes a later cancel a no-op while the setup keeps running", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + const fiber = yield* Effect.forkChild(Effect.never); + yield* tracker.begin({ threadId, branch: null, baseRef: null, stages: ["agent"], fiber }); + + yield* tracker.markUncancellable(threadId); + expect(yield* tracker.cancel(threadId)).toBe(false); + expect((yield* tracker.get(threadId))?.phase).toBe("running"); + yield* Fiber.interrupt(fiber); + }), + ); + + it.effect("clamps free text to the contract limits before publishing", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + yield* tracker.begin({ + threadId, + branch: null, + baseRef: null, + stages: ["checkout", "setup-script"], + fiber: null, + }); + const long = "x".repeat(2_000); + + yield* tracker.stageStatus(threadId, "checkout", "done", long); + yield* tracker.stage(threadId, "setup-script", { detail: long }); + yield* tracker.appendTail(threadId, "setup-script", long); + yield* tracker.finish(threadId, "failed", long); + + const snapshot = yield* tracker.get(threadId); + expect(snapshot?.stages[0]?.detail?.length).toBe(200); + expect(snapshot?.stages[1]?.detail?.length).toBe(200); + expect(snapshot?.stages[1]?.tail[0]?.length).toBe(400); + expect(snapshot?.error?.length).toBe(1000); + // The wire schema must accept what the tracker publishes. + expect(Schema.is(WorktreeSetupSnapshot)(snapshot)).toBe(true); + }), + ); +}); diff --git a/apps/server/src/project/WorktreeSetupTracker.ts b/apps/server/src/project/WorktreeSetupTracker.ts new file mode 100644 index 0000000000..016b256a81 --- /dev/null +++ b/apps/server/src/project/WorktreeSetupTracker.ts @@ -0,0 +1,370 @@ +import type { + ThreadId, + WorktreeSetupSnapshot, + WorktreeSetupStage, + WorktreeSetupStageId, + WorktreeSetupStageStatus, +} from "@t3tools/contracts"; +import { + WORKTREE_SETUP_DETAIL_MAX_LENGTH, + WORKTREE_SETUP_ERROR_MAX_LENGTH, + WORKTREE_SETUP_STAGE_ORDER, + WORKTREE_SETUP_TAIL_LINE_MAX_LENGTH, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Scope from "effect/Scope"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +/** + * Tracks the live stages of a bootstrap worktree setup per thread so clients + * can render a progress card while the first turn is still being prepared. + * + * State is memory only. It exists from the first `begin` until the turn starts + * or the setup fails, plus a short grace window so a client that subscribes + * late still sees the final state. Nothing here is persisted or event-sourced: + * the durable record is written by the bootstrap flow as worktree setup + * activities, alongside the thread's worktree path and setup script activities. + */ +export class WorktreeSetupTracker extends Context.Service< + WorktreeSetupTracker, + { + /** Creates a fresh running snapshot for the thread, replacing any prior one. */ + readonly begin: (input: { + readonly threadId: ThreadId; + readonly branch: string | null; + readonly baseRef: string | null; + readonly stages: ReadonlyArray; + /** Interrupting this fiber cancels the bootstrap. */ + readonly fiber: Fiber.Fiber | null; + }) => Effect.Effect; + readonly update: ( + threadId: ThreadId, + mutate: (snapshot: WorktreeSetupSnapshot) => WorktreeSetupSnapshot, + ) => Effect.Effect; + readonly stage: ( + threadId: ThreadId, + stageId: WorktreeSetupStageId, + patch: Partial>, + ) => Effect.Effect; + readonly stageStatus: ( + threadId: ThreadId, + stageId: WorktreeSetupStageId, + status: WorktreeSetupStageStatus, + detail?: string | null, + ) => Effect.Effect; + readonly appendTail: ( + threadId: ThreadId, + stageId: WorktreeSetupStageId, + line: string, + ) => Effect.Effect; + /** Returns the settled snapshot, or null when nothing was tracked. */ + readonly finish: ( + threadId: ThreadId, + phase: "done" | "failed" | "cancelled", + error?: string | null, + ) => Effect.Effect; + /** + * Drops the cancel handle. Called right before the turn is dispatched so a + * late cancel cannot roll back a thread whose agent has already started. + */ + readonly markUncancellable: (threadId: ThreadId) => Effect.Effect; + /** + * Interrupts the running bootstrap and waits for it to unwind, so the + * caller's dispatch has already failed and rolled back when this returns. + * Returns false when nothing is running or the setup is past cancellation. + */ + readonly cancel: (threadId: ThreadId) => Effect.Effect; + readonly get: (threadId: ThreadId) => Effect.Effect; + /** Emits the current snapshot (or null) first, then every change until unsubscribed. */ + readonly stream: (threadId: ThreadId) => Stream.Stream; + } +>()("t3/project/WorktreeSetupTracker") {} + +const TAIL_LINE_LIMIT = 4; + +/** Keeps free text inside the contract limit, ending in an ellipsis when cut. */ +function clampText(text: string, maxLength: number): string { + return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}\u2026`; +} + +const clampDetail = (detail: string | null): string | null => + detail === null ? null : clampText(detail, WORKTREE_SETUP_DETAIL_MAX_LENGTH); +/** Finished snapshots stay visible this long so a late subscriber sees the outcome. */ +const FINISHED_RETENTION = "30 seconds"; + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +interface TrackedSetup { + readonly snapshot: WorktreeSetupSnapshot; + readonly fiber: Fiber.Fiber | null; +} + +function emptyStage(id: WorktreeSetupStageId): WorktreeSetupStage { + return { + id, + status: "pending", + startedAt: null, + endedAt: null, + percent: null, + detail: null, + tail: [], + }; +} + +export const make = Effect.gen(function* () { + const retentionScope = yield* Scope.make("parallel"); + yield* Effect.addFinalizer(() => Scope.close(retentionScope, Exit.void)); + const setups = yield* Ref.make(new Map()); + const changes = yield* PubSub.unbounded<{ + readonly threadId: ThreadId; + readonly snapshot: WorktreeSetupSnapshot | null; + }>(); + const retentionFibers = new Map>(); + // Sequences keep increasing across setups of the same thread so a stream + // opened during a previous setup still accepts the next one's first snapshot. + const lastSequenceByThread = new Map(); + + const publish = (threadId: ThreadId, snapshot: WorktreeSetupSnapshot | null) => + PubSub.publish(changes, { threadId, snapshot }).pipe(Effect.asVoid); + + const modify = ( + threadId: ThreadId, + mutate: (tracked: TrackedSetup) => TrackedSetup, + ): Effect.Effect => + Ref.modify(setups, (current) => { + const existing = current.get(threadId); + if (!existing) return [null, current] as const; + const nextTracked = mutate(existing); + const nextSnapshot = { + ...nextTracked.snapshot, + sequence: existing.snapshot.sequence + 1, + }; + lastSequenceByThread.set(threadId, nextSnapshot.sequence); + const next = new Map(current); + next.set(threadId, { ...nextTracked, snapshot: nextSnapshot }); + return [nextSnapshot, next] as const; + }).pipe(Effect.tap((snapshot) => (snapshot ? publish(threadId, snapshot) : Effect.void))); + + const clearRetention = (threadId: ThreadId) => { + const fiber = retentionFibers.get(threadId); + retentionFibers.delete(threadId); + return fiber ? Fiber.interrupt(fiber).pipe(Effect.ignore) : Effect.void; + }; + + const remove = (threadId: ThreadId) => + Ref.update(setups, (current) => { + if (!current.has(threadId)) return current; + const next = new Map(current); + next.delete(threadId); + return next; + }).pipe( + Effect.andThen(publish(threadId, null)), + // A subscriber that outlives retention sees `null` here and accepts any + // sequence after it, so the counter can start over for this thread. + Effect.tap(() => Effect.sync(() => lastSequenceByThread.delete(threadId))), + ); + + const begin: WorktreeSetupTracker["Service"]["begin"] = (input) => + Effect.gen(function* () { + yield* clearRetention(input.threadId); + const startedAt = yield* nowIso; + const ordered = WORKTREE_SETUP_STAGE_ORDER.filter((id) => input.stages.includes(id)); + const snapshot: WorktreeSetupSnapshot = { + threadId: input.threadId, + phase: "running", + startedAt, + endedAt: null, + branch: input.branch, + baseRef: input.baseRef, + worktreePath: null, + setupScript: null, + stages: ordered.map(emptyStage), + error: null, + sequence: (lastSequenceByThread.get(input.threadId) ?? -1) + 1, + }; + lastSequenceByThread.set(input.threadId, snapshot.sequence); + yield* Ref.update(setups, (current) => { + const next = new Map(current); + next.set(input.threadId, { snapshot, fiber: input.fiber }); + return next; + }); + yield* publish(input.threadId, snapshot); + }); + + const update: WorktreeSetupTracker["Service"]["update"] = (threadId, mutate) => + modify(threadId, (tracked) => ({ ...tracked, snapshot: mutate(tracked.snapshot) })).pipe( + Effect.asVoid, + ); + + const stage: WorktreeSetupTracker["Service"]["stage"] = (threadId, stageId, patch) => + update(threadId, (snapshot) => ({ + ...snapshot, + stages: snapshot.stages.map((entry) => + entry.id === stageId + ? { + ...entry, + ...patch, + ...(patch.detail === undefined ? {} : { detail: clampDetail(patch.detail ?? null) }), + } + : entry, + ), + })); + + const stageStatus: WorktreeSetupTracker["Service"]["stageStatus"] = ( + threadId, + stageId, + status, + detail, + ) => + nowIso.pipe( + Effect.flatMap((at) => + update(threadId, (snapshot) => ({ + ...snapshot, + stages: snapshot.stages.map((entry) => { + if (entry.id !== stageId) return entry; + const startedAt = entry.startedAt ?? (status === "pending" ? null : at); + const endedAt = + status === "running" || status === "pending" ? null : (entry.endedAt ?? at); + return { + ...entry, + status, + startedAt, + endedAt, + ...(detail === undefined ? {} : { detail: clampDetail(detail ?? null) }), + }; + }), + })), + ), + ); + + const appendTail: WorktreeSetupTracker["Service"]["appendTail"] = (threadId, stageId, line) => + update(threadId, (snapshot) => ({ + ...snapshot, + stages: snapshot.stages.map((entry) => + entry.id === stageId + ? { + ...entry, + tail: [...entry.tail, clampText(line, WORKTREE_SETUP_TAIL_LINE_MAX_LENGTH)].slice( + -TAIL_LINE_LIMIT, + ), + } + : entry, + ), + })); + + const finish: WorktreeSetupTracker["Service"]["finish"] = (threadId, phase, error) => + Effect.gen(function* () { + const endedAt = yield* nowIso; + const snapshot = yield* modify(threadId, (tracked) => ({ + fiber: null, + snapshot: { + ...tracked.snapshot, + phase, + endedAt, + error: + error === undefined || error === null + ? null + : clampText(error, WORKTREE_SETUP_ERROR_MAX_LENGTH), + stages: tracked.snapshot.stages.map((entry) => + entry.status === "running" + ? { + ...entry, + status: phase === "done" ? "done" : phase === "cancelled" ? "skipped" : "failed", + endedAt, + } + : entry, + ), + }, + })); + if (!snapshot) return null; + yield* clearRetention(threadId); + const fiber = yield* remove(threadId).pipe( + Effect.delay(FINISHED_RETENTION), + Effect.ensuring( + Effect.sync(() => { + // Only drop our own entry: a newer setup may have replaced it. + if (retentionFibers.get(threadId) === fiber) retentionFibers.delete(threadId); + }), + ), + Effect.forkIn(retentionScope), + ); + retentionFibers.set(threadId, fiber); + return snapshot; + }); + + const markUncancellable: WorktreeSetupTracker["Service"]["markUncancellable"] = (threadId) => + Ref.update(setups, (current) => { + const existing = current.get(threadId); + if (!existing || existing.fiber === null) return current; + const next = new Map(current); + next.set(threadId, { ...existing, fiber: null }); + return next; + }); + + const cancel: WorktreeSetupTracker["Service"]["cancel"] = (threadId) => + Effect.gen(function* () { + const current = yield* Ref.get(setups); + const tracked = current.get(threadId); + if (!tracked || tracked.snapshot.phase !== "running" || !tracked.fiber) { + return false; + } + yield* Fiber.interrupt(tracked.fiber); + return true; + }); + + const get: WorktreeSetupTracker["Service"]["get"] = (threadId) => + Ref.get(setups).pipe(Effect.map((current) => current.get(threadId)?.snapshot ?? null)); + + /** + * Each subscriber gets a one-slot sliding mailbox: a slow WebSocket only + * ever holds the newest snapshot, so a chatty setup script cannot grow the + * server heap. Snapshots are whole states, so skipping intermediates is safe. + */ + const stream: WorktreeSetupTracker["Service"]["stream"] = (threadId) => + Stream.callback( + (mailbox) => + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + const initial = yield* get(threadId); + // Changes published between subscribing and reading `initial` are + // already folded into it. Drop them so the client never steps back. + let lastSequence = initial?.sequence ?? -1; + Queue.offerUnsafe(mailbox, initial); + yield* Stream.fromSubscription(subscription).pipe( + Stream.runForEach((change) => + Effect.sync(() => { + if (change.threadId !== threadId) return; + if (change.snapshot !== null && change.snapshot.sequence <= lastSequence) return; + lastSequence = change.snapshot?.sequence ?? -1; + Queue.offerUnsafe(mailbox, change.snapshot); + }), + ), + Effect.forkScoped, + ); + }), + { bufferSize: 1, strategy: "sliding" }, + ); + + return WorktreeSetupTracker.of({ + begin, + update, + stage, + stageStatus, + appendTail, + finish, + markUncancellable, + cancel, + get, + stream, + }); +}); + +export const layer = Layer.effect(WorktreeSetupTracker, make); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 1c02d8c1a4..122cc745d2 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -743,6 +743,7 @@ const makeThreadProjectProjectionLayer = ( getDeliveredDelegationNotificationIds: () => Effect.succeed([]), getPendingRequestActivities: () => Effect.die("unused"), getUserInputActivity: () => Effect.die("unused"), + listActivitiesByKind: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 10fea8ecc5..465f0c5ddf 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -255,6 +255,7 @@ describe("ProviderSessionReaper", () => { getDeliveredDelegationNotificationIds: () => Effect.succeed([]), getPendingRequestActivities: () => Effect.succeed([]), getUserInputActivity: () => Effect.die("unused"), + listActivitiesByKind: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 4e2229fbdb..d661087f9c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -48,6 +48,9 @@ import { WS_METHODS, WsRpcGroup, type LaunchEditorInput, + EditorId, + WorktreeSetupSnapshot, + type WorktreeSetupStageId, } from "@t3tools/contracts"; import { computeDpopAccessTokenHash, @@ -92,6 +95,13 @@ import * as Socket from "effect/unstable/socket/Socket"; import { vi } from "vite-plus/test"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +const SUCCESSFUL_GIT_EXECUTION = { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}; const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationThreadDetailSnapshot), ); @@ -145,6 +155,7 @@ import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; +import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; @@ -911,6 +922,7 @@ const buildAppUnderTest = (options?: { }), ), ), + WorktreeSetupTracker.layer, ), ), Layer.provide( @@ -10863,14 +10875,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { status: "started" as const, scriptId: "setup", scriptName: "Setup", + scriptCommand: "npm install", terminalId: "setup-setup", cwd: "/tmp/bootstrap-worktree", + async: true, }), ); yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), remoteExists, fetchRemote, remoteBranchExists, @@ -10935,17 +10953,33 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(response.sequence, 5); + assert.equal(response.sequence, 8); assert.deepEqual( dispatchedCommands.map((command) => command.type), [ "thread.create", + "thread.message.user.append", + "thread.activity.append", + "thread.session.set", "thread.meta.update", "thread.activity.append", "thread.activity.append", "thread.turn.start", + "thread.activity.append", ], ); + // The checkout can take minutes, so the thread reads as working from + // the moment setup starts rather than only once the turn is dispatched. + const preparingCommand = dispatchedCommands[3]; + assertTrue(preparingCommand?.type === "thread.session.set"); + if (preparingCommand?.type === "thread.session.set") { + assert.equal(preparingCommand.session.status, "starting"); + assert.equal(preparingCommand.session.activeTurnId, null); + assert.equal( + preparingCommand.session.providerInstanceId, + defaultModelSelection.instanceId, + ); + } assert.deepEqual(createWorktree.mock.calls[0]?.[0], { cwd: "/tmp/project", refName: fetchedOriginCommit, @@ -10974,12 +11008,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "resolve-remote-commit", "create-worktree", ]); - assert.deepEqual(runForThread.mock.calls[0]?.[0], { - threadId: ThreadId.make("thread-bootstrap"), - projectId: defaultProjectId, - projectCwd: "/tmp/project", - worktreePath: "/tmp/bootstrap-worktree", - }); + const runForThreadInput = runForThread.mock.calls[0]?.[0]; + assert.deepEqual( + runForThreadInput && { + threadId: runForThreadInput.threadId, + projectId: runForThreadInput.projectId, + projectCwd: runForThreadInput.projectCwd, + worktreePath: runForThreadInput.worktreePath, + }, + { + threadId: ThreadId.make("thread-bootstrap"), + projectId: defaultProjectId, + projectCwd: "/tmp/project", + worktreePath: "/tmp/bootstrap-worktree", + }, + ); + // Worktree bootstraps observe script completion so the setup card can show the exit code. + assert.isDefined(runForThreadInput?.observeCompletion); assert.deepEqual(refreshStatus.mock.calls[0]?.[0], "/tmp/bootstrap-worktree"); const setupActivities = dispatchedCommands.filter( @@ -10988,9 +11033,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.deepEqual( setupActivities.map((command) => command.activity.kind), - ["setup-script.requested", "setup-script.started"], + ["worktree-setup", "setup-script.requested", "setup-script.started", "worktree-setup"], ); - const finalCommand = dispatchedCommands[4]; + // The setup record is upserted under one id: running once the thread + // exists, settled at the end, so a late client renders the outcome + // without the in-memory tracker. + const runningActivity = setupActivities[0]?.activity; + const settledActivity = setupActivities.at(-1)?.activity; + assert.equal(runningActivity?.id, settledActivity?.id); + assert.equal(settledActivity?.tone, "info"); + assertTrue(Schema.is(WorktreeSetupSnapshot)(runningActivity?.payload)); + if (Schema.is(WorktreeSetupSnapshot)(runningActivity?.payload)) { + assert.equal(runningActivity.payload.phase, "running"); + } + assertTrue(Schema.is(WorktreeSetupSnapshot)(settledActivity?.payload)); + if (Schema.is(WorktreeSetupSnapshot)(settledActivity?.payload)) { + assert.equal(settledActivity.payload.phase, "done"); + assert.equal(settledActivity.payload.threadId, ThreadId.make("thread-bootstrap")); + } + const finalCommand = dispatchedCommands[7]; assertTrue(finalCommand?.type === "thread.turn.start"); if (finalCommand?.type === "thread.turn.start") { assert.equal(finalCommand.bootstrap, undefined); @@ -11034,7 +11095,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), remoteExists, fetchRemote, remoteBranchExists, @@ -11116,6 +11181,179 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("falls back to the project checkout when worktree mode targets a non-repository", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.die(new Error("createWorktree must not run for a non-repository")), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-turn-start-non-repo"), + threadId: ThreadId.make("thread-bootstrap-non-repo"), + message: { + messageId: MessageId.make("msg-bootstrap-non-repo"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + }, + runSetupScript: true, + }, + createdAt, + }), + ), + ); + + assert.equal(response.sequence, 4); + assert.equal(createWorktree.mock.calls.length, 0); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + [ + "thread.create", + "thread.message.user.append", + "thread.activity.append", + "thread.turn.start", + "thread.activity.append", + ], + ); + const finalCommand = dispatchedCommands[3]; + assertTrue(finalCommand?.type === "thread.turn.start"); + if (finalCommand?.type === "thread.turn.start") { + assert.equal(finalCommand.bootstrap, undefined); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("falls back to the project checkout when the worktree base has no commit", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.die(new Error("createWorktree must not run without a base commit")), + ); + + yield* buildAppUnderTest({ + layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, + gitVcsDriver: { + execute: () => + Effect.succeed({ + ...SUCCESSFUL_GIT_EXECUTION, + exitCode: ChildProcessSpawner.ExitCode(128), + stderr: "fatal: Needed a single revision", + }), + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-turn-start-unborn-head"), + threadId: ThreadId.make("thread-bootstrap-unborn-head"), + message: { + messageId: MessageId.make("msg-bootstrap-unborn-head"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + }, + runSetupScript: true, + }, + createdAt, + }), + ), + ); + + assert.equal(response.sequence, 4); + assert.equal(createWorktree.mock.calls.length, 0); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + [ + "thread.create", + "thread.message.user.append", + "thread.activity.append", + "thread.turn.start", + "thread.activity.append", + ], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; @@ -11146,7 +11384,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), createWorktree, }, orchestrationEngine: { @@ -11203,14 +11445,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(response.sequence, 4); + assert.equal(response.sequence, 7); assert.deepEqual( dispatchedCommands.map((command) => command.type), - ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], + [ + "thread.create", + "thread.message.user.append", + "thread.activity.append", + "thread.session.set", + "thread.meta.update", + "thread.activity.append", + "thread.turn.start", + "thread.activity.append", + ], ); const setupFailureActivity = dispatchedCommands.find( (command): command is Extract => - command.type === "thread.activity.append", + command.type === "thread.activity.append" && command.activity.kind !== "worktree-setup", ); assert.equal(setupFailureActivity?.activity.kind, "setup-script.failed"); assert.deepEqual(setupFailureActivity?.activity.payload, { @@ -11243,15 +11494,21 @@ it.layer(NodeServices.layer)("server router seam", (it) => { status: "started" as const, scriptId: "setup", scriptName: "Setup", + scriptCommand: "npm install", terminalId: "setup-setup", cwd: "/tmp/bootstrap-worktree", + async: true, }), ); let setupActivityAppendAttempt = 0; yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), createWorktree, }, orchestrationEngine: { @@ -11324,10 +11581,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(response.sequence, 4); + assert.equal(response.sequence, 7); assert.deepEqual( dispatchedCommands.map((command) => command.type), - ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], + [ + "thread.create", + "thread.message.user.append", + "thread.activity.append", + "thread.session.set", + "thread.meta.update", + "thread.activity.append", + "thread.turn.start", + "thread.activity.append", + ], ); const setupActivities = dispatchedCommands.filter( (command): command is Extract => @@ -11335,7 +11601,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.deepEqual( setupActivities.map((command) => command.activity.kind), - ["setup-script.requested"], + ["worktree-setup", "setup-script.requested", "worktree-setup"], ); assertTrue( setupActivities.every((command) => command.activity.kind !== "setup-script.failed"), @@ -11344,6 +11610,229 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect.each([ + { + caseName: "async setup scripts let the turn start before the script exits", + async: true, + cancel: false, + interruptPreparing: false, + }, + { + caseName: "sync setup scripts hold the turn until the script exits", + async: false, + cancel: false, + interruptPreparing: false, + }, + { + caseName: "cancelling worktree setup publishes its outcome and cleans up the thread", + async: false, + cancel: true, + interruptPreparing: false, + }, + { + caseName: "interrupted preparing receipt settles the session when rollback fails", + async: false, + cancel: false, + interruptPreparing: true, + }, + ])("$caseName", ({ async, cancel, interruptPreparing }) => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const scriptExit = yield* Deferred.make(); + const runForThread = vi.fn( + ( + _: Parameters< + ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"] + >[0], + ) => + Effect.succeed({ + status: "started" as const, + scriptId: "setup", + scriptName: "Setup", + scriptCommand: "npm install", + terminalId: "setup-setup", + cwd: "/tmp/bootstrap-worktree", + async, + completion: Deferred.await(scriptExit).pipe(Effect.as({ exitCode: 0, durationMs: 1 })), + }), + ); + + yield* buildAppUnderTest({ + layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, + gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), + createWorktree: () => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.gen(function* () { + dispatchedCommands.push(command); + if ( + interruptPreparing && + command.type === "thread.session.set" && + command.session.status === "starting" + ) { + return yield* Effect.interrupt; + } + if (interruptPreparing && command.type === "thread.delete") { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Simulated rollback failure", + }); + } + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + projectSetupScriptRunner: { + runForThread, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const threadId = ThreadId.make(`thread-bootstrap-${async ? "async" : "sync"}-setup`); + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchFiber = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-bootstrap-${async ? "async" : "sync"}-setup`), + threadId, + message: { + messageId: MessageId.make("msg-bootstrap-setup"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + }, + runSetupScript: true, + }, + createdAt, + }), + ), + ).pipe(Effect.forkChild); + + if (interruptPreparing) { + const result = yield* Fiber.join(dispatchFiber).pipe(Effect.result); + assertTrue(result._tag === "Failure"); + assertTrue( + dispatchedCommands.some( + (command) => + command.type === "thread.session.set" && command.session.status === "error", + ), + ); + assert.isFalse(dispatchedCommands.some((command) => command.type === "thread.turn.start")); + assert.equal(runForThread.mock.calls.length, 0); + return; + } + + const turnStarted = () => + dispatchedCommands.some((command) => command.type === "thread.turn.start"); + const snapshotWhere = (predicate: (snapshot: WorktreeSetupSnapshot) => boolean) => + Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeWorktreeSetup]({ threadId }).pipe( + Stream.filter( + (snapshot): snapshot is WorktreeSetupSnapshot => + snapshot !== null && predicate(snapshot), + ), + Stream.runHead, + Effect.map(Option.getOrThrow), + ), + ), + ); + const stageStatus = (snapshot: WorktreeSetupSnapshot, id: WorktreeSetupStageId) => + snapshot.stages.find((stage) => stage.id === id)?.status; + + if (async) { + // The turn is dispatched while the script is still running. + const started = yield* snapshotWhere( + (snapshot) => stageStatus(snapshot, "agent") === "done", + ); + assertTrue(turnStarted()); + assert.equal(started.phase, "running"); + assert.equal(stageStatus(started, "setup-script"), "running"); + yield* Fiber.join(dispatchFiber); + + yield* Deferred.succeed(scriptExit, undefined); + const settled = yield* snapshotWhere((snapshot) => snapshot.phase !== "running"); + assert.equal(settled.phase, "done"); + assert.equal(stageStatus(settled, "setup-script"), "done"); + return; + } + + // The script is running and the turn has not been dispatched yet. + const running = yield* snapshotWhere( + (snapshot) => stageStatus(snapshot, "setup-script") === "running", + ); + assert.equal(stageStatus(running, "agent"), "pending"); + assert.isFalse(turnStarted()); + + if (cancel) { + const cancelled = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.worktreeSetupCancel]({ threadId })), + ); + assert.isTrue(cancelled.cancelled); + assertTrue(dispatchedCommands.some((command) => command.type === "thread.delete")); + const outcome = dispatchedCommands.findLast( + (command) => + command.type === "thread.activity.append" && command.activity.kind === "worktree-setup", + ); + assertTrue(outcome?.type === "thread.activity.append"); + assert.propertyVal(outcome.activity.payload, "phase", "cancelled"); + const result = yield* Fiber.join(dispatchFiber).pipe(Effect.result); + assertTrue(result._tag === "Failure"); + assert.propertyVal(result.failure, "message", "Worktree setup cancelled."); + assert.propertyVal(result.failure, "bootstrapThreadDisposition", "deleted"); + assert.isFalse(turnStarted()); + return; + } + + // The client that sent the message goes away mid-setup (a reload or a + // dropped socket). The bootstrap belongs to the server, not the + // connection: the thread already exists for every client, so it must + // finish and start the turn regardless. + yield* Fiber.interrupt(dispatchFiber); + assert.isFalse(turnStarted()); + + yield* Deferred.succeed(scriptExit, undefined); + yield* snapshotWhere((snapshot) => stageStatus(snapshot, "agent") === "done"); + assertTrue(turnStarted()); + const settled = yield* snapshotWhere((snapshot) => snapshot.phase !== "running"); + assert.equal(settled.phase, "done"); + assert.equal(stageStatus(settled, "setup-script"), "done"); + assert.equal(stageStatus(settled, "agent"), "done"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("cleans up created bootstrap threads when worktree creation defects", () => Effect.gen(function* () { const dispatchedCommands: Array = []; @@ -11356,7 +11845,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const config = yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), createWorktree, }, orchestrationEngine: { @@ -11438,7 +11931,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.strictEqual(result.failure.bootstrapThreadDisposition, "deleted"); assert.deepEqual( dispatchedCommands.map((command) => command.type), - ["thread.create", "thread.delete"], + [ + "thread.create", + "thread.message.user.append", + "thread.activity.append", + "thread.session.set", + "thread.activity.append", + "thread.delete", + ], ); assert.isDefined(pendingAttachmentId); assert.isTrue( @@ -11551,7 +12051,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), ), ); - assert.deepEqual(trace, ["thread.create", "drain:1", "thread.turn.start"]); + assert.deepEqual(trace, [ + "thread.create", + "drain:1", + "thread.message.user.append", + "thread.turn.start", + ]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -11565,7 +12070,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), createWorktree, }, orchestrationEngine: { @@ -11632,8 +12141,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.strictEqual(result.failure.bootstrapThreadDisposition, undefined); assert.deepEqual( dispatchedCommands.map((command) => command.type), - ["thread.create", "thread.delete"], + [ + "thread.create", + "thread.message.user.append", + "thread.activity.append", + "thread.session.set", + "thread.activity.append", + "thread.delete", + "thread.session.set", + ], ); + // The surviving thread must not keep its preparing session, or it would + // read as working forever. + const failedSession = dispatchedCommands[6]; + assertTrue(failedSession?.type === "thread.session.set"); + if (failedSession?.type === "thread.session.set") { + assert.equal(failedSession.session.status, "error"); + assert.include(failedSession.session.lastError ?? "", "worktree exploded"); + } }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d2547ee8b8..5f5952164c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -105,6 +105,7 @@ import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; @@ -362,6 +363,7 @@ const PullRequestServiceLive = PullRequestService.layer.pipe( const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(ProjectSetupScriptRunner.layer.pipe(Layer.provide(ServerSettingsLayerLive))), + Layer.provideMerge(WorktreeSetupTracker.layer), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(TextGeneration.layer), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index b8fb4b613a..ba653fa3fb 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -167,6 +167,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getDeliveredDelegationNotificationIds: () => Effect.succeed([]), getPendingRequestActivities: () => Effect.succeed([]), getUserInputActivity: () => Effect.die("unused"), + listActivitiesByKind: () => Effect.succeed([]), getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), @@ -299,6 +300,7 @@ it.effect.each([ getDeliveredDelegationNotificationIds: () => Effect.succeed([]), getPendingRequestActivities: () => Effect.succeed([]), getUserInputActivity: () => Effect.die("unused"), + listActivitiesByKind: () => Effect.succeed([]), getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), @@ -388,6 +390,7 @@ it.effect( getDeliveredDelegationNotificationIds: () => Effect.succeed([]), getPendingRequestActivities: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), + listActivitiesByKind: () => Effect.succeed([]), getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), @@ -453,6 +456,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getDeliveredDelegationNotificationIds: () => Effect.succeed([]), getPendingRequestActivities: () => Effect.succeed([]), getUserInputActivity: () => Effect.die("unused"), + listActivitiesByKind: () => Effect.succeed([]), getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 87cd0b9275..eb0db0f202 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -1,5 +1,6 @@ import { CommandId, + EventId, DEFAULT_MODEL, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_SERVER_SETTINGS, @@ -11,6 +12,9 @@ import { ProviderInstanceId, ThreadId, TurnId, + WORKTREE_SETUP_ACTIVITY_KIND, + WorktreeSetupSnapshot, + worktreeSetupActivityId, } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; @@ -833,6 +837,91 @@ export const reconcileProviderSessions = Effect.gen(function* () { ), ); +const decodeWorktreeSetupSnapshot = Schema.decodeUnknownOption(WorktreeSetupSnapshot); + +/** + * A worktree bootstrap records its setup snapshot on the thread while it runs + * and settles it when it finishes. The bootstrap itself lives only in memory, + * so a process exit mid-setup leaves a `running` record with nobody to finish + * it. Before the turn started that also strands the persisted user message, so + * the setup is marked failed and the user is told to send again. After the + * handoff only an async setup script was still running; its stage is marked + * failed and the setup settles as done, like any other script failure. + */ +export const reconcileWorktreeSetups = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; + const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + // The command read model carries no activity bodies; read the setup + // records directly, live threads only. + const recordedSetups = yield* query.listActivitiesByKind(WORKTREE_SETUP_ACTIVITY_KIND); + const interruptedAt = DateTime.formatIso(yield* DateTime.now); + + for (const recorded of recordedSetups) { + const snapshot = decodeWorktreeSetupSnapshot(recorded.payload); + if (Option.isNone(snapshot) || snapshot.value.phase !== "running") continue; + if (recorded.id !== worktreeSetupActivityId(snapshot.value.threadId)) continue; + const threadId = snapshot.value.threadId; + + const turnStarted = snapshot.value.stages.some( + (stage) => stage.id === "agent" && stage.status === "done", + ); + const interrupted: WorktreeSetupSnapshot = { + ...snapshot.value, + phase: turnStarted ? "done" : "failed", + endedAt: interruptedAt, + error: turnStarted + ? null + : "The server restarted before the worktree setup finished. Send the message again.", + stages: snapshot.value.stages.map((stage) => + stage.status === "running" || stage.status === "pending" + ? { + ...stage, + status: "failed", + endedAt: interruptedAt, + detail: "interrupted by a server restart", + } + : stage, + ), + sequence: snapshot.value.sequence + 1, + }; + yield* orchestrationEngine + .dispatch({ + type: "thread.activity.append", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId, + activity: { + id: EventId.make(worktreeSetupActivityId(threadId)), + tone: "error", + kind: WORKTREE_SETUP_ACTIVITY_KIND, + summary: turnStarted + ? "Setup script interrupted by a server restart" + : "Worktree setup interrupted by a server restart", + payload: interrupted, + turnId: null, + createdAt: snapshot.value.startedAt, + }, + createdAt: interruptedAt, + }) + .pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("failed to settle interrupted worktree setup", { + threadId, + cause, + }), + ), + ); + } +}).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("worktree setup startup reconciliation failed", { cause }), + ), +); + interface StartupOptions { readonly activate?: Effect.Effect; readonly awaitAuxiliaryParked?: Effect.Effect; @@ -972,6 +1061,7 @@ export const make = (options?: StartupOptions) => ); yield* runStartupPhase("provider-sessions.reconcile", reconcileProviderSessions); + yield* runStartupPhase("worktree-setups.reconcile", reconcileWorktreeSetups); yield* Effect.logDebug("startup phase: syncing clean projects"); yield* runStartupPhase("projects.auto-pull", syncAutoPullProjects); diff --git a/apps/server/src/serverRuntimeStartup.worktreeSetup.test.ts b/apps/server/src/serverRuntimeStartup.worktreeSetup.test.ts new file mode 100644 index 0000000000..fda60d8897 --- /dev/null +++ b/apps/server/src/serverRuntimeStartup.worktreeSetup.test.ts @@ -0,0 +1,156 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + EventId, + type OrchestrationCommand, + ThreadId, + WORKTREE_SETUP_ACTIVITY_KIND, + WorktreeSetupSnapshot, + worktreeSetupActivityId, + type WorktreeSetupPhase, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; + +const startedAt = "2026-08-20T12:00:00.000Z"; + +const snapshotFor = ( + threadId: ThreadId, + phase: WorktreeSetupPhase, + agentStatus: "pending" | "done" = phase === "running" ? "pending" : "done", +): WorktreeSetupSnapshot => ({ + threadId, + phase, + startedAt, + endedAt: phase === "running" ? null : startedAt, + branch: "feature", + baseRef: "main", + worktreePath: null, + setupScript: null, + stages: [ + { + id: "checkout", + status: "done", + startedAt, + endedAt: startedAt, + percent: null, + detail: null, + tail: [], + }, + { + id: "setup-script", + status: phase === "running" ? "running" : "done", + startedAt, + endedAt: phase === "running" ? null : startedAt, + percent: null, + detail: null, + tail: [], + }, + { + id: "agent", + status: agentStatus, + startedAt: null, + endedAt: null, + percent: null, + detail: null, + tail: [], + }, + ], + error: null, + sequence: 4, +}); + +const recordedSetup = (id: string, phase: WorktreeSetupPhase, agentStatus?: "pending" | "done") => { + const threadId = ThreadId.make(id); + return { + id: EventId.make(worktreeSetupActivityId(threadId)), + tone: "info" as const, + kind: WORKTREE_SETUP_ACTIVITY_KIND, + summary: "Setting up worktree", + payload: snapshotFor(threadId, phase, agentStatus), + turnId: null, + createdAt: startedAt, + }; +}; + +const run = (activities: ReadonlyArray>) => + Effect.gen(function* () { + const dispatched: Array = []; + yield* ServerRuntimeStartup.reconcileWorktreeSetups.pipe( + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + listActivitiesByKind: (kind: string) => + Effect.succeed(kind === WORKTREE_SETUP_ACTIVITY_KIND ? activities : []), + } as unknown as ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }), + Effect.provide(NodeServices.layer), + ); + return dispatched; + }); + +it.effect("marks setups still recorded as running failed after a restart", () => + Effect.gen(function* () { + const dispatched = yield* run([ + recordedSetup("thread-running", "running"), + recordedSetup("thread-done", "done"), + recordedSetup("thread-failed", "failed"), + ]); + + assert.equal(dispatched.length, 1); + const command = dispatched[0]!; + assert.equal(command.type, "thread.activity.append"); + if (command.type !== "thread.activity.append") return; + assert.equal(command.threadId, ThreadId.make("thread-running")); + assert.equal(command.activity.id, worktreeSetupActivityId(ThreadId.make("thread-running"))); + assert.equal(command.activity.tone, "error"); + const payload = yield* Schema.decodeUnknownEffect(WorktreeSetupSnapshot)( + command.activity.payload, + ); + assert.equal(payload.phase, "failed"); + assert.isNotNull(payload.endedAt); + assert.equal(payload.sequence, 5); + assert.deepEqual( + payload.stages.map((stage) => stage.status), + ["done", "failed", "failed"], + ); + }), +); + +it.effect( + "settles an async setup script whose turn already started without failing the setup", + () => + Effect.gen(function* () { + const dispatched = yield* run([recordedSetup("thread-async", "running", "done")]); + + assert.equal(dispatched.length, 1); + const command = dispatched[0]!; + if (command.type !== "thread.activity.append") return assert.fail(command.type); + const payload = yield* Schema.decodeUnknownEffect(WorktreeSetupSnapshot)( + command.activity.payload, + ); + // The turn is live; only the background script was lost. Nothing asks the + // user to resend, and the setup reads as done with a failed script stage. + assert.equal(payload.phase, "done"); + assert.isNull(payload.error); + assert.deepEqual( + payload.stages.map((stage) => stage.status), + ["done", "failed", "done"], + ); + }), +); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 26e81f187a..0d10d7d5a0 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1919,13 +1919,15 @@ it.layer( it.effect("injects runtime env overrides into spawned terminals", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); + const { manager, ptyAdapter } = yield* createManager(5, { env: { FORCE_COLOR: "3" } }); yield* manager.open( openInput({ env: { T3CODE_PROJECT_ROOT: "/repo", T3CODE_WORKTREE_PATH: "/repo/worktree-a", CUSTOM_FLAG: "1", + NO_COLOR: "1", + FORCE_COLOR: "0", }, }), ); @@ -1936,6 +1938,8 @@ it.layer( assert.equal(spawnInput.env.T3CODE_PROJECT_ROOT, "/repo"); assert.equal(spawnInput.env.T3CODE_WORKTREE_PATH, "/repo/worktree-a"); assert.equal(spawnInput.env.CUSTOM_FLAG, "1"); + assert.equal(spawnInput.env.NO_COLOR, "1"); + assert.equal(spawnInput.env.FORCE_COLOR, "0"); }), ); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 07b8c6795c..bf156873dd 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -109,6 +109,36 @@ export interface ExecuteGitProgress { }) => Effect.Effect; } +/** + * Progress callbacks for `createWorktree`. Git prints `Updating files: 78% (2104/2700)` + * to stderr during checkout, and `Submodule path 'x': checked out` during + * submodule init. The tracker uses these to drive the worktree setup card. + */ +export interface CreateWorktreeProgress { + /** + * Fires once `git worktree add` has created and registered the directory, + * before the (possibly long) submodule step. Git refuses an existing path, + * so a path reported here belongs to this call and is safe to remove on + * cancel. + */ + readonly onWorktreeClaimed?: (path: string) => Effect.Effect; + readonly onCheckoutProgress?: (input: { + percent: number; + completed: number; + total: number; + }) => Effect.Effect; + readonly onSubmodulesStarted?: () => Effect.Effect; + readonly onSubmoduleLine?: (line: string) => Effect.Effect; + readonly onSubmodulesFinished?: (input: { + ok: boolean; + detail: string | null; + }) => Effect.Effect; +} + +export interface CreateWorktreeOptions { + readonly progress?: CreateWorktreeProgress; +} + export interface GitCommitProgress { readonly onOutputLine?: (input: { stream: "stdout" | "stderr"; @@ -287,6 +317,7 @@ export class GitVcsDriver extends Context.Service< readonly pullCurrentBranch: (cwd: string) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, + options?: CreateWorktreeOptions, ) => Effect.Effect; readonly fetchPullRequestBranch: ( input: GitFetchPullRequestBranchInput, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 974f17590f..da67fca641 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -23,7 +23,11 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, type ReviewDiffFileContentsInput } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; import { gitCommandDuration } from "../observability/Metrics.ts"; -import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; +import { + makeGitVcsDriverCore, + parseGitCheckoutProgressLine, + splitNullSeparatedGitStdoutPaths, +} from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -1718,6 +1722,20 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { + it("parses checkout progress lines from git's stderr", () => { + assert.deepStrictEqual(parseGitCheckoutProgressLine("Updating files: 78% (2104/2700)"), { + percent: 78, + completed: 2104, + total: 2700, + }); + // Progress lines arrive carriage-return separated and end with a done marker. + assert.deepStrictEqual( + parseGitCheckoutProgressLine("Updating files: 100% (2700/2700), done."), + { percent: 100, completed: 2700, total: 2700 }, + ); + assert.strictEqual(parseGitCheckoutProgressLine("Preparing worktree (new branch 'x')"), null); + }); + // NTFS rejects a newline in a file name, so there is nothing to preserve there. it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( "preserves newline characters in worktree paths when listing refs", @@ -1833,6 +1851,53 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reports checkout progress while creating a worktree", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + for (let index = 0; index < 5; index += 1) { + yield* writeTextFile(cwd, `file-${index}.txt`, `${index}\n`); + } + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add files"]); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "progress-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const seen = yield* Ref.make>( + [], + ); + const claimed = yield* Ref.make<{ path: string; existed: boolean } | null>(null); + + yield* driver.createWorktree( + { cwd, path: worktreePath, refName: initialBranch, newRefName: "feature/progress" }, + { + progress: { + onWorktreeClaimed: (path) => + Ref.set(claimed, { path, existed: NodeFS.existsSync(path) }), + onCheckoutProgress: (update) => Ref.update(seen, (all) => [...all, update]), + }, + }, + ); + // Claimed only once git has registered the directory. + assert.deepEqual(yield* Ref.get(claimed), { path: worktreePath, existed: true }); + + // Git separates live progress updates with `\r`, so the driver must + // surface every intermediate percentage, not just the final line. + const updates = yield* Ref.get(seen); + assert.isAbove(updates.length, 1); + assert.equal(updates.at(-1)?.percent, 100); + assert.equal(updates.at(-1)?.total, 6); + const completed = updates.map((update) => update.completed); + assert.deepEqual( + completed, + completed.toSorted((a, b) => a - b), + ); + }), + ); + it.effect("creates and removes a worktree for a new refName", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3e750e68a1..25f14d58bb 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -642,6 +642,25 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( }; }); +const GIT_CHECKOUT_PROGRESS_LINE = /Updating files:\s+(\d+)%\s+\((\d+)\/(\d+)\)/; + +/** Parses `Updating files: 78% (2104/2700)` from git's stderr progress output. */ +export function parseGitCheckoutProgressLine( + line: string, +): { percent: number; completed: number; total: number } | null { + const match = GIT_CHECKOUT_PROGRESS_LINE.exec(line); + if (!match) return null; + const percent = Number(match[1]); + const completed = Number(match[2]); + const total = Number(match[3]); + if (!Number.isFinite(percent) || !Number.isFinite(completed) || !Number.isFinite(total)) { + return null; + } + return { percent: Math.max(0, Math.min(100, percent)), completed, total }; +} + +const OUTPUT_LINE_SEPARATOR = /\r\n|\r|\n/; + const collectOutput = Effect.fnUntraced(function* ( input: Pick, stream: Stream.Stream, @@ -662,21 +681,21 @@ const collectOutput = Effect.fnUntraced(function* ( // without bound; a line longer than this is not one the callbacks want. const maxPendingLineBytes = 64 * 1024; + // Git redraws progress with a bare `\r` between updates and only ends the + // line once the step is done, so `\r` has to count as a line break here. const emitCompleteLines = Effect.fnUntraced(function* (flush: boolean) { - while (lineBuffer.length > 0) { - const match = /[\r\n]/.exec(lineBuffer); - if (!match) break; - const index = match.index; - const delimiterLength = lineBuffer[index] === "\r" && lineBuffer[index + 1] === "\n" ? 2 : 1; - const line = lineBuffer.slice(0, index); - lineBuffer = lineBuffer.slice(index + delimiterLength); + let separator = OUTPUT_LINE_SEPARATOR.exec(lineBuffer); + while (separator) { + const line = lineBuffer.slice(0, separator.index); + lineBuffer = lineBuffer.slice(separator.index + separator[0].length); if (line.length > 0 && onLine) { yield* onLine(line); } + separator = OUTPUT_LINE_SEPARATOR.exec(lineBuffer); } if (flush) { - const trailing = lineBuffer.replace(/[\r\n]+$/, ""); + const trailing = lineBuffer; lineBuffer = ""; if (trailing.length > 0 && onLine) { yield* onLine(trailing); @@ -3032,7 +3051,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( "createWorktree", - )(function* (input) { + )(function* (input, options) { const targetBranch = input.newRefName ?? input.refName; const sanitizedBranch = targetBranch.replace(/\//g, "-"); const repoName = path.basename(input.cwd); @@ -3040,12 +3059,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const args = input.newRefName ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] : ["worktree", "add", worktreePath, input.refName]; + const progress = options?.progress; + const onCheckoutProgress = progress?.onCheckoutProgress; yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { fallbackErrorDetail: "git worktree add failed", timeoutMs: WORKTREE_ADD_TIMEOUT_MS, + ...(onCheckoutProgress + ? { + // Git only prints checkout progress when stderr is a tty or the + // delay elapsed. GIT_PROGRESS_DELAY=0 forces it through the pipe. + env: { GIT_PROGRESS_DELAY: "0", LC_ALL: "C" }, + progress: { + onStderrLine: (line) => { + const parsed = parseGitCheckoutProgressLine(line); + return parsed ? onCheckoutProgress(parsed) : Effect.void; + }, + }, + } + : {}), }); + if (progress?.onWorktreeClaimed) { + yield* progress.onWorktreeClaimed(worktreePath); + } + // `git worktree add` leaves submodules empty, so a repo that keeps agent // skills, tooling or source in one gets a worktree that is quietly missing // them. Best-effort: the objects are usually already in the parent's @@ -3055,18 +3093,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* .exists(path.join(worktreePath, ".gitmodules")) .pipe(Effect.orElseSucceed(() => false)); if (hasSubmodules) { - yield* runGit("GitVcsDriver.createWorktree.updateSubmodules", worktreePath, [ - "submodule", - "update", - "--init", - "--recursive", - ]).pipe( - Effect.catch((cause) => - Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { - worktreePath, - cause, - }), - ), + if (progress?.onSubmodulesStarted) { + yield* progress.onSubmodulesStarted(); + } + const onSubmoduleLine = progress?.onSubmoduleLine; + yield* runGit( + "GitVcsDriver.createWorktree.updateSubmodules", + worktreePath, + ["submodule", "update", "--init", "--recursive"], + onSubmoduleLine + ? { + env: { LC_ALL: "C" }, + progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine }, + } + : {}, + ).pipe( + Effect.matchEffect({ + onFailure: (cause) => + Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { + worktreePath, + cause, + }).pipe( + Effect.andThen( + progress?.onSubmodulesFinished + ? progress.onSubmodulesFinished({ ok: false, detail: cause.message }) + : Effect.void, + ), + ), + onSuccess: () => + progress?.onSubmodulesFinished + ? progress.onSubmodulesFinished({ ok: true, detail: null }) + : Effect.void, + }), ); } @@ -3518,7 +3576,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* getReviewDiffFileContents, readConfigValue, listRefs, - createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)), + createWorktree: (input, options) => + withListRefsInvalidation(input.cwd, createWorktree(input, options)), fetchPullRequestBranch: (input) => withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), fetchPullRequestHeadCommit, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 702a350172..688042ace6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -13,11 +13,13 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { @@ -84,6 +86,9 @@ import { type PullRequestRef, WS_METHODS, WsRpcGroup, + WORKTREE_SETUP_ACTIVITY_KIND, + worktreeSetupActivityId, + type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { isDriveOrPosixAbsolutePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; @@ -161,6 +166,7 @@ import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; +import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -619,6 +625,7 @@ const makeWsRpcLayer = ( | ServerConfig.ServerConfig | WorkspacePaths.WorkspacePaths >(); + const worktreeSetupTracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; @@ -791,6 +798,44 @@ const makeWsRpcLayer = ( ), ); + // The worktree setup's durable record: one activity per thread, upserted + // by a fixed id when the setup starts and again when it settles. Live + // progress keeps streaming from the tracker; this is what a reload or + // another client reads. Best effort: the thread may already be gone + // after a failed bootstrap. + const recordWorktreeSetup = (snapshot: WorktreeSetupSnapshot) => + serverCommandId("worktree-setup-activity").pipe( + Effect.flatMap((commandId) => + dispatchFromClient({ + type: "thread.activity.append", + commandId, + threadId: snapshot.threadId, + activity: { + id: EventId.make(worktreeSetupActivityId(snapshot.threadId)), + tone: + snapshot.phase === "failed" || + snapshot.stages.some((stage) => stage.status === "failed") + ? "error" + : "info", + kind: WORKTREE_SETUP_ACTIVITY_KIND, + summary: + snapshot.phase === "running" + ? "Setting up worktree" + : snapshot.phase === "done" + ? "Worktree ready" + : snapshot.phase === "cancelled" + ? "Worktree setup cancelled" + : "Worktree setup failed", + payload: snapshot, + turnId: null, + createdAt: snapshot.startedAt, + }, + createdAt: snapshot.endedAt ?? snapshot.startedAt, + }), + ), + Effect.ignoreCause({ log: true }), + ); + const toBootstrapDispatchCommandCauseError = (cause: Cause.Cause) => { const error = Cause.squash(cause); return isOrchestrationDispatchCommandError(error) @@ -1029,7 +1074,34 @@ const makeWsRpcLayer = ( let targetProjectId = bootstrap?.createThread?.projectId; let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd; let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null; + // The setup script's terminal, once started. Cancel closes only this + // one so terminals the user opened meanwhile survive. + let setupTerminalId: string | null = null; + // Set once the checkout starts; see the session.set below. + let preparingSessionSet = false; + const markPreparingSessionFailed = (detail: string) => + Effect.gen(function* () { + const failedAt = yield* nowIso; + yield* dispatchFromClient({ + type: "thread.session.set", + commandId: yield* serverCommandId("bootstrap-thread-preparing-failed"), + threadId, + session: { + threadId, + status: "error", + providerName: null, + providerInstanceId: + bootstrap?.createThread?.modelSelection.instanceId ?? + command.modelSelection?.instanceId, + runtimeMode: command.runtimeMode, + activeTurnId: null, + lastError: detail.trim().length > 0 ? detail : "Worktree setup failed.", + updatedAt: failedAt, + }, + createdAt: failedAt, + }); + }); const cleanupCreatedThread = () => createdThread ? serverCommandId("bootstrap-thread-delete").pipe( @@ -1121,19 +1193,38 @@ const makeWsRpcLayer = ( ); }); + const tracked = bootstrap?.prepareWorktree !== undefined; + const threadId = command.threadId; + const track = (effect: Effect.Effect) => (tracked ? effect : Effect.void); + + // Starts the setup script. For tracked bootstraps it returns the + // effect that waits for the script to exit and records the outcome + // on the card; whether the agent stage waits on it depends on the + // script's `async` flag. Returns null when nothing is left to await. + // Untracked callers keep the old fire-and-forget behavior. const runSetupProgram = () => Effect.gen(function* () { if (!bootstrap?.runSetupScript || !targetWorktreePath) { - return; + yield* track(worktreeSetupTracker.stageStatus(threadId, "setup-script", "skipped")); + return null; } const worktreePath = targetWorktreePath; const requestedAt = yield* nowIso; - yield* projectSetupScriptRunner + yield* track(worktreeSetupTracker.stageStatus(threadId, "setup-script", "running")); + const setupResult = yield* projectSetupScriptRunner .runForThread({ - threadId: command.threadId, + threadId, ...(targetProjectId ? { projectId: targetProjectId } : {}), ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), worktreePath, + ...(tracked + ? { + observeCompletion: { + onOutputLine: (line) => + worktreeSetupTracker.appendTail(threadId, "setup-script", line), + }, + } + : {}), }) .pipe( Effect.matchEffect({ @@ -1142,127 +1233,524 @@ const makeWsRpcLayer = ( error, requestedAt, worktreePath, - }), + }).pipe( + Effect.andThen( + track( + worktreeSetupTracker.stageStatus( + threadId, + "setup-script", + "failed", + "failed to start", + ), + ), + ), + Effect.as(null), + ), onSuccess: (setupResult) => { if (setupResult.status !== "started") { - return Effect.void; + return track( + worktreeSetupTracker.stageStatus( + threadId, + "setup-script", + "skipped", + "no setup script", + ), + ).pipe(Effect.as(null)); } + setupTerminalId = setupResult.terminalId; return recordSetupScriptStarted({ requestedAt, worktreePath, scriptId: setupResult.scriptId, scriptName: setupResult.scriptName, terminalId: setupResult.terminalId, - }); + }).pipe( + Effect.andThen( + track( + worktreeSetupTracker.update(threadId, (snapshot) => ({ + ...snapshot, + setupScript: { + name: setupResult.scriptName, + command: setupResult.scriptCommand, + terminalId: setupResult.terminalId, + }, + })), + ), + ), + Effect.as(setupResult), + ); }, }), ); + if (!tracked || !setupResult?.completion) { + return null; + } + // The setup script is best effort, like the untracked path: a + // failed install must not throw away the worktree the user just + // waited for. The card keeps the failed stage and its terminal. + // Forked right away so the terminal listener behind `completion` + // is always consumed, even when the turn dispatch fails before + // anyone would otherwise wait on it. The tracker update is a + // no-op once the snapshot has been dropped. + const completionFiber = yield* setupResult.completion.pipe( + Effect.flatMap((completion) => { + if (completion.exitCode === 0) { + return worktreeSetupTracker.stageStatus(threadId, "setup-script", "done"); + } + const detail = + completion.exitCode === null + ? "terminal closed before the script finished" + : `exit ${completion.exitCode}`; + return worktreeSetupTracker.stageStatus( + threadId, + "setup-script", + "failed", + detail, + ); + }), + Effect.forkDetach, + ); + if (!setupResult.async) { + yield* Fiber.join(completionFiber); + return null; + } + return completionFiber; }); const bootstrapProgram = Effect.gen(function* () { - if (bootstrap?.createThread) { - const created = yield* dispatchFromClient({ - type: "thread.create", - commandId: yield* serverCommandId("bootstrap-thread-create"), - threadId: command.threadId, - projectId: bootstrap.createThread.projectId, - title: bootstrap.createThread.title, - modelSelection: bootstrap.createThread.modelSelection, - runtimeMode: bootstrap.createThread.runtimeMode, - interactionMode: bootstrap.createThread.interactionMode, - branch: bootstrap.createThread.branch, - worktreePath: bootstrap.createThread.worktreePath, - createdAt: bootstrap.createThread.createdAt, - }); - // The successful create is a fence in the engine command queue: - // every delete for the prior incarnation committed before it. - // Drain through that event before setup or turn start can own - // terminals and provider sessions under the reused thread id. - yield* threadDeletionReactor.drainThrough(created.sequence); - createdThread = true; - } + const prepareWorktree = bootstrap?.prepareWorktree; + let shouldPrepareWorktree = prepareWorktree + ? yield* gitWorkflow.isRepository(prepareWorktree.projectCwd) + : false; + let worktreeBaseRef = prepareWorktree?.baseBranch ?? null; - if (bootstrap?.prepareWorktree) { - let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; + if (prepareWorktree && shouldPrepareWorktree) { // "Start from origin" is a stored default; repos without the // requested remote branch fall back to the local base branch. const startFromOrigin = - bootstrap.prepareWorktree.startFromOrigin === true && + prepareWorktree.startFromOrigin === true && (yield* gitWorkflow.remoteExists({ - cwd: bootstrap.prepareWorktree.projectCwd, + cwd: prepareWorktree.projectCwd, remoteName: "origin", })); if (startFromOrigin) { + yield* track(worktreeSetupTracker.stageStatus(threadId, "fetch", "running")); yield* gitWorkflow.fetchRemote({ - cwd: bootstrap.prepareWorktree.projectCwd, + cwd: prepareWorktree.projectCwd, remoteName: "origin", }); const remoteBaseExists = yield* gitWorkflow.remoteBranchExists({ - cwd: bootstrap.prepareWorktree.projectCwd, - refName: bootstrap.prepareWorktree.baseBranch, + cwd: prepareWorktree.projectCwd, + refName: prepareWorktree.baseBranch, remoteName: "origin", }); if (remoteBaseExists) { const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ - cwd: bootstrap.prepareWorktree.projectCwd, - refName: bootstrap.prepareWorktree.baseBranch, + cwd: prepareWorktree.projectCwd, + refName: prepareWorktree.baseBranch, fallbackRemoteName: "origin", }); worktreeBaseRef = resolvedRemoteBase.commitSha; + yield* track( + worktreeSetupTracker.stageStatus( + threadId, + "fetch", + "done", + `origin/${prepareWorktree.baseBranch} at ${resolvedRemoteBase.commitSha.slice(0, 7)}`, + ), + ); + } else { + yield* track( + worktreeSetupTracker.stageStatus( + threadId, + "fetch", + "warning", + `origin/${prepareWorktree.baseBranch} not found, using local branch`, + ), + ); } + } else { + yield* track(worktreeSetupTracker.stageStatus(threadId, "fetch", "skipped")); } - const worktree = yield* gitWorkflow.createWorktree({ - cwd: bootstrap.prepareWorktree.projectCwd, - refName: worktreeBaseRef, - newRefName: bootstrap.prepareWorktree.branch, - baseRefName: bootstrap.prepareWorktree.baseBranch, - path: null, + + const resolvedWorktreeBaseRef = worktreeBaseRef ?? prepareWorktree.baseBranch; + shouldPrepareWorktree = yield* gitWorkflow.hasCommit({ + cwd: prepareWorktree.projectCwd, + refName: resolvedWorktreeBaseRef, + }); + worktreeBaseRef = resolvedWorktreeBaseRef; + yield* track( + worktreeSetupTracker.update(threadId, (snapshot) => ({ + ...snapshot, + baseRef: resolvedWorktreeBaseRef, + })), + ); + } + + if (prepareWorktree && !shouldPrepareWorktree) { + // Not a git repo, or the base has no commit: the thread runs in + // the project checkout instead. The card says so and moves on. + yield* track( + worktreeSetupTracker.update(threadId, (snapshot) => ({ + ...snapshot, + stages: snapshot.stages.map((stage) => + stage.id === "fetch" || stage.id === "checkout" || stage.id === "submodules" + ? { ...stage, status: "skipped", detail: "using project checkout" } + : stage, + ), + })), + ); + } + + if (bootstrap?.createThread) { + const created = yield* Effect.uninterruptible( + dispatchFromClient({ + type: "thread.create", + commandId: yield* serverCommandId("bootstrap-thread-create"), + threadId: command.threadId, + projectId: bootstrap.createThread.projectId, + title: bootstrap.createThread.title, + modelSelection: bootstrap.createThread.modelSelection, + runtimeMode: bootstrap.createThread.runtimeMode, + interactionMode: bootstrap.createThread.interactionMode, + branch: bootstrap.createThread.branch, + worktreePath: bootstrap.createThread.worktreePath, + createdAt: bootstrap.createThread.createdAt, + }).pipe( + Effect.tap(() => + Effect.sync(() => { + createdThread = true; + }), + ), + ), + ); + // The successful create is a fence in the engine command queue: + // every delete for the prior incarnation committed before it. + // Drain through that event before setup or turn start can own + // terminals and provider sessions under the reused thread id. + yield* threadDeletionReactor.drainThrough(created.sequence); + // Persist the send now rather than with the turn: the thread is + // real from here on, so any client (or a reload) sees the message + // while the worktree is still being prepared. The turn start + // later references this id instead of re-sending the text. + yield* dispatchFromClient({ + type: "thread.message.user.append", + commandId: yield* serverCommandId("bootstrap-thread-message"), + threadId: command.threadId, + message: { + messageId: command.message.messageId, + text: command.message.text, + attachments: command.message.attachments, + ...(command.message.context !== undefined + ? { context: command.message.context } + : {}), + }, + createdAt: command.createdAt, }); + if (tracked) { + const running = yield* worktreeSetupTracker.get(threadId); + if (running) yield* recordWorktreeSetup(running); + } + } + + if (prepareWorktree && shouldPrepareWorktree && worktreeBaseRef) { + if (bootstrap?.createThread && createdThread) { + // The checkout and setup script can run for minutes before the + // turn starts. The prompt is already durable, but no agent + // owns it yet. Project a starting session now so every + // client lists the thread as working and a reopened thread + // knows to follow the setup stream. A failed or cancelled setup + // deletes the thread, so nothing lingers. + const preparingAt = yield* nowIso; + yield* dispatchFromClient({ + type: "thread.session.set", + commandId: yield* serverCommandId("bootstrap-thread-preparing"), + threadId, + session: { + threadId, + status: "starting", + providerName: null, + providerInstanceId: bootstrap.createThread.modelSelection.instanceId, + runtimeMode: command.runtimeMode, + activeTurnId: null, + lastError: null, + updatedAt: preparingAt, + }, + createdAt: preparingAt, + }).pipe( + Effect.ensuring( + Effect.sync(() => { + preparingSessionSet = true; + }), + ), + ); + } + yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running"); + let checkoutTotal: number | null = null; + const worktree = yield* gitWorkflow.createWorktree( + { + cwd: prepareWorktree.projectCwd, + refName: worktreeBaseRef, + newRefName: prepareWorktree.branch, + baseRefName: prepareWorktree.baseBranch, + path: null, + }, + { + progress: { + // Git has registered the directory at this point, so a + // cancel during the submodule step can still remove it. + onWorktreeClaimed: (path) => + Effect.sync(() => { + targetWorktreePath = path; + }), + onCheckoutProgress: ({ percent, completed, total }) => { + checkoutTotal = total; + return worktreeSetupTracker.stage(threadId, "checkout", { + percent, + detail: `${completed.toLocaleString("en-US")} / ${total.toLocaleString("en-US")} files`, + }); + }, + onSubmodulesStarted: () => + worktreeSetupTracker + .stageStatus( + threadId, + "checkout", + "done", + checkoutTotal === null + ? null + : `${checkoutTotal.toLocaleString("en-US")} files`, + ) + .pipe( + Effect.andThen( + worktreeSetupTracker.stageStatus(threadId, "submodules", "running"), + ), + ), + onSubmoduleLine: (line) => { + const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1]; + return submodulePath === undefined + ? Effect.void + : worktreeSetupTracker.stage(threadId, "submodules", { + detail: submodulePath, + }); + }, + onSubmodulesFinished: ({ ok, detail }) => + worktreeSetupTracker.stageStatus( + threadId, + "submodules", + ok ? "done" : "warning", + ok ? undefined : (detail ?? "submodule checkout failed"), + ), + }, + }, + ); + const checkoutEndedAt = yield* nowIso; + yield* worktreeSetupTracker.update(threadId, (snapshot) => ({ + ...snapshot, + worktreePath: worktree.worktree.path, + stages: snapshot.stages.map((stage) => { + if (stage.id === "checkout" && stage.status === "running") { + return { + ...stage, + status: "done", + percent: 100, + endedAt: checkoutEndedAt, + detail: + checkoutTotal === null + ? stage.detail + : `${checkoutTotal.toLocaleString("en-US")} files`, + }; + } + if (stage.id === "submodules" && stage.status === "pending") { + return { ...stage, status: "skipped", detail: "none" }; + } + return stage; + }), + })); targetWorktreePath = worktree.worktree.path; yield* dispatchFromClient({ type: "thread.meta.update", commandId: yield* serverCommandId("bootstrap-thread-meta-update"), - threadId: command.threadId, + threadId, branch: worktree.worktree.refName, worktreePath: targetWorktreePath, }); yield* refreshGitStatus(targetWorktreePath); } - yield* runSetupProgram(); + const pendingSetupScript = yield* runSetupProgram(); - return yield* dispatchFromClient(finalTurnStartCommand); + yield* track(worktreeSetupTracker.stageStatus(threadId, "agent", "running")); + // Past this point a cancel would roll back a thread whose turn has + // started. Drop the cancel handle and make the handoff atomic. + yield* track(worktreeSetupTracker.markUncancellable(threadId)); + const started = yield* Effect.uninterruptible( + dispatchFromClient(finalTurnStartCommand), + ); + yield* track(worktreeSetupTracker.stageStatus(threadId, "agent", "done")); + // An async setup script outlives the handoff: the snapshot stays + // running so the client keeps its row next to the agent's work, + // and settles when the script exits. The turn already started, so + // the wait cannot fail the dispatch. + const settle = tracked + ? worktreeSetupTracker + .finish(threadId, "done") + .pipe( + Effect.flatMap((snapshot) => + snapshot ? recordWorktreeSetup(snapshot) : Effect.void, + ), + ) + : Effect.void; + if (pendingSetupScript) { + yield* Fiber.join(pendingSetupScript).pipe( + Effect.ignoreCause({ log: true }), + Effect.andThen(settle), + Effect.forkDetach, + ); + } else { + yield* settle; + } + return started; }); - return yield* bootstrapProgram.pipe( + const cleanupAndFail = ( + cause: Cause.Cause, + dispatchError: OrchestrationDispatchCommandError, + ) => + Effect.uninterruptible(cleanupCreatedThread()).pipe( + Effect.matchCauseEffect({ + onFailure: (cleanupCause) => + Effect.logWarning("bootstrap thread cleanup failed", { + threadId, + detail: Cause.pretty(cleanupCause), + }).pipe( + // The thread outlived its setup. Its preparing session + // must not read as working forever, so record the failure + // on it instead. + Effect.andThen( + preparingSessionSet + ? markPreparingSessionFailed(dispatchError.message).pipe( + Effect.ignoreCause({ log: true }), + ) + : Effect.void, + ), + Effect.flatMap(() => Effect.fail(dispatchError)), + ), + onSuccess: (threadDeleted) => + Effect.fail( + threadDeleted + ? new OrchestrationDispatchCommandError({ + message: dispatchError.message, + ...(dispatchError.cause !== undefined + ? { cause: dispatchError.cause } + : {}), + bootstrapThreadDisposition: "deleted", + }) + : dispatchError, + ), + }), + ); + + const settledBootstrapProgram = bootstrapProgram.pipe( + Effect.interruptible, Effect.catchCause((cause) => { const dispatchError = toBootstrapDispatchCommandCauseError(cause); if (Cause.hasInterruptsOnly(cause)) { - return Effect.fail(dispatchError); + // A user cancel interrupts the forked bootstrap fiber. The + // created thread is rolled back like any other failure so the + // draft returns to the composer. The setup terminal is closed + // first so a still-running script cannot hold files open in + // the worktree while git removes it. Closing kills the + // process asynchronously, so the removal retries briefly. + const closeSetupTerminal = setupTerminalId + ? terminalManager.close({ + threadId, + terminalId: setupTerminalId, + deleteHistory: true, + }) + : Effect.void; + const removeCreatedWorktree = + tracked && targetWorktreePath && bootstrap?.prepareWorktree + ? closeSetupTerminal.pipe( + Effect.ignoreCause({ log: true }), + Effect.andThen( + gitWorkflow + .removeWorktree({ + cwd: bootstrap.prepareWorktree.projectCwd, + path: targetWorktreePath, + force: true, + }) + .pipe( + Effect.retry({ times: 4, schedule: Schedule.spaced("500 millis") }), + ), + ), + Effect.ignoreCause({ log: true }), + Effect.uninterruptible, + ) + : Effect.void; + return track( + worktreeSetupTracker + .finish(threadId, "cancelled") + .pipe( + Effect.flatMap((snapshot) => + snapshot ? recordWorktreeSetup(snapshot) : Effect.void, + ), + ), + ).pipe( + Effect.andThen(removeCreatedWorktree), + Effect.andThen( + tracked + ? cleanupAndFail( + cause, + new OrchestrationDispatchCommandError({ + message: "Worktree setup cancelled.", + }), + ) + : Effect.fail(dispatchError), + ), + ); } - return Effect.uninterruptible(cleanupCreatedThread()).pipe( - Effect.matchCauseEffect({ - onFailure: (cleanupCause) => - Effect.logWarning("bootstrap thread cleanup failed", { - threadId: command.threadId, - detail: Cause.pretty(cleanupCause), - }).pipe(Effect.flatMap(() => Effect.fail(dispatchError))), - onSuccess: (threadDeleted) => - Effect.fail( - threadDeleted - ? new OrchestrationDispatchCommandError({ - message: dispatchError.message, - ...(dispatchError.cause !== undefined - ? { cause: dispatchError.cause } - : {}), - bootstrapThreadDisposition: "deleted", - }) - : dispatchError, + return track( + worktreeSetupTracker + .finish(threadId, "failed", dispatchError.message) + .pipe( + Effect.flatMap((snapshot) => + snapshot ? recordWorktreeSetup(snapshot) : Effect.void, ), - }), - ); + ), + ).pipe(Effect.andThen(cleanupAndFail(cause, dispatchError))); }), + // Cancellation must finish recording and rollback after the bootstrap is interrupted. + Effect.uninterruptible, ); + + // The bootstrap outlives the connection that asked for it: a reload + // or a dropped socket must not abandon a half-made worktree, and + // the thread it created is already visible to every client. The + // RPC only waits on the detached fiber; a user cancel interrupts it + // through the tracker. + const runBootstrap = tracked + ? Effect.gen(function* () { + // Fork and register as one step: a detached fiber keeps going + // if the caller is interrupted, so it must never exist without + // the tracker entry that cancel and the stage updates key on. + const fiber = yield* Effect.uninterruptible( + Effect.gen(function* () { + const fiber = yield* Effect.forkDetach(settledBootstrapProgram); + yield* worktreeSetupTracker.begin({ + threadId, + branch: bootstrap?.prepareWorktree?.branch ?? null, + baseRef: bootstrap?.prepareWorktree?.baseBranch ?? null, + stages: ["fetch", "checkout", "submodules", "setup-script", "agent"], + fiber, + }); + return fiber; + }), + ); + return yield* Fiber.join(fiber); + }) + : settledBootstrapProgram; + + return yield* runBootstrap; }); const dispatchNormalizedCommand = ( @@ -2985,6 +3473,20 @@ const makeWsRpcLayer = ( "rpc.aggregate": "vcs", }, ), + [WS_METHODS.subscribeWorktreeSetup]: (input) => + observeRpcStream( + WS_METHODS.subscribeWorktreeSetup, + worktreeSetupTracker.stream(input.threadId), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.worktreeSetupCancel]: (input) => + observeRpcEffect( + WS_METHODS.worktreeSetupCancel, + worktreeSetupTracker + .cancel(input.threadId) + .pipe(Effect.map((cancelled) => ({ cancelled }))), + { "rpc.aggregate": "vcs" }, + ), [WS_METHODS.vcsRefreshStatus]: (input) => observeRpcEffect( WS_METHODS.vcsRefreshStatus, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 21c2deb55b..5ccbcc3f08 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -12,6 +12,7 @@ import { ThreadId, TurnId, type ServerProvider, + type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { Atom, AsyncResult } from "effect/unstable/reactivity"; @@ -61,9 +62,12 @@ import { resolveBackgroundDraftWorkspaceOptions, restorePlanFollowUpComposer, resolveDraftPromotionNavigationTarget, + findRecordedWorktreeSetup, + resolveVisibleWorktreeSetup, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, threadShellHasStarted, + canFinalizePromotedDraft, resolveDraftHeroState, isPaintOnlyThreadTimeline, peekHeldThreadTimeline, @@ -566,6 +570,30 @@ describe("draft hero submission transition", () => { ).toBe(false); }); + it("leaves the hero layout while a worktree setup card is on the timeline", () => { + expect( + resolveDraftHeroState({ + isLocalDraftThread: true, + hasTimelineEntries: false, + isWorking: false, + draftHeroDockRequested: false, + backgroundSubmissionPending: false, + hasWorktreeSetupCard: true, + }), + ).toBe(false); + // A background submission normally pins the hero, but never over the card. + expect( + resolveDraftHeroState({ + isLocalDraftThread: true, + hasTimelineEntries: false, + isWorking: false, + draftHeroDockRequested: false, + backgroundSubmissionPending: true, + hasWorktreeSetupCard: true, + }), + ).toBe(false); + }); + it("keeps the composer in the hero layout until navigation after server promotion", () => { expect( resolveDraftHeroState({ @@ -961,20 +989,10 @@ describe("draft promotion during worktree setup", () => { const serverThreadRef = { environmentId, threadId }; it.each([null, "idle", "starting", "ready"] as const)( - "keeps the draft mounted while the first turn waits with session %s", + "keeps the draft mounted until the server owns the send, with session %s", (status) => { const serverThread = makeThread({ - messages: [ - { - id: MessageId.make("submitted-message"), - role: "user", - text: "Start in a new worktree", - turnId: null, - createdAt: now, - updatedAt: now, - streaming: false, - }, - ], + messages: [], session: status ? { ...readySession, status } : null, }); @@ -988,6 +1006,31 @@ describe("draft promotion during worktree setup", () => { }, ); + it("promotes once the bootstrap persisted the user message, before any turn", () => { + const serverThread = makeThread({ + messages: [ + { + id: MessageId.make("submitted-message"), + role: "user", + text: "Start in a new worktree", + turnId: null, + createdAt: now, + updatedAt: now, + streaming: false, + }, + ], + session: null, + }); + + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef, + serverThread, + backgroundSubmissionPending: false, + }), + ).toEqual(serverThreadRef); + }); + it("promotes when the provider starts the first turn", () => { const latestTurn = { ...completedTurn, state: "running" as const, completedAt: null }; @@ -2582,3 +2625,124 @@ describe("restorePlanFollowUpComposer", () => { }); }); }); + +describe("worktree setup visibility", () => { + const stage = ( + id: "fetch" | "checkout" | "submodules" | "setup-script" | "agent", + status: "done" | "running" | "failed" | "pending", + ) => ({ + id, + status, + startedAt: now, + endedAt: status === "running" || status === "pending" ? null : now, + percent: null, + detail: null, + tail: [], + }); + const base = { + threadId, + phase: "running" as const, + startedAt: now, + endedAt: null, + branch: "feature", + baseRef: "main", + worktreePath: null, + setupScript: null, + stages: [stage("checkout", "running"), stage("agent", "pending")], + error: null, + sequence: 1, + }; + const settledDone = { + ...base, + phase: "done" as const, + endedAt: now, + stages: [stage("checkout", "done"), stage("setup-script", "done"), stage("agent", "done")], + }; + + it("reads the settled snapshot back from the thread's activities", () => { + const activities = [ + { kind: "setup-script.started", payload: {} }, + { kind: "worktree-setup", payload: settledDone }, + { kind: "worktree-setup", payload: { not: "a snapshot" } }, + ]; + expect(findRecordedWorktreeSetup(activities, threadId)).toEqual(settledDone); + expect(findRecordedWorktreeSetup(activities, ThreadId.make("other"))).toBeNull(); + }); + + it("shows a running setup and drops a clean one once the turn started", () => { + const visible = (snapshot: WorktreeSetupSnapshot | null, turnStarted: boolean) => + resolveVisibleWorktreeSetup({ + live: null, + recorded: snapshot, + turnStarted, + followUpSent: false, + }); + expect( + resolveVisibleWorktreeSetup({ + live: base, + recorded: null, + turnStarted: false, + followUpSent: false, + }), + ).toEqual(base); + expect(visible(settledDone, false)).toEqual(settledDone); + expect(visible(settledDone, true)).toBeNull(); + expect(visible(null, true)).toBeNull(); + }); + + it("keeps a failed script, a failed setup, and a cancelled setup visible", () => { + const scriptFailed = { + ...settledDone, + stages: [stage("checkout", "done"), stage("setup-script", "failed"), stage("agent", "done")], + }; + const visible = (snapshot: WorktreeSetupSnapshot, followUpSent = false) => + resolveVisibleWorktreeSetup({ + live: null, + recorded: snapshot, + turnStarted: true, + followUpSent, + }); + expect(visible(scriptFailed)).toEqual(scriptFailed); + const failed = { ...settledDone, phase: "failed" as const, error: "git exploded" }; + expect(visible(failed)).toEqual(failed); + const cancelled = { ...settledDone, phase: "cancelled" as const }; + expect(visible(cancelled)).toEqual(cancelled); + + // The setup belongs to the first turn. A follow-up send retires every + // settled outcome; only a script that is still running stays. + expect(visible(scriptFailed, true)).toBeNull(); + expect(visible(failed, true)).toBeNull(); + expect(visible(cancelled, true)).toBeNull(); + expect(visible(settledDone, true)).toBeNull(); + const stillRunning = { + ...base, + stages: [stage("checkout", "done"), stage("setup-script", "running"), stage("agent", "done")], + }; + expect(visible(stillRunning, true)).toEqual(stillRunning); + }); + + it("prefers whichever snapshot is newer by sequence", () => { + const pick = (live: WorktreeSetupSnapshot | null, recorded: WorktreeSetupSnapshot | null) => + resolveVisibleWorktreeSetup({ live, recorded, turnStarted: false, followUpSent: false }); + expect(pick({ ...base, sequence: 3 }, { ...settledDone, sequence: 7 })).toEqual({ + ...settledDone, + sequence: 7, + }); + expect(pick({ ...settledDone, sequence: 9 }, { ...base, sequence: 1 })).toEqual({ + ...settledDone, + sequence: 9, + }); + }); +}); + +describe("draft retention through worktree setup", () => { + it("retains the retry draft until the preparing session has an agent", () => { + const preparing = makeThread({ + latestTurn: null, + session: { ...readySession, status: "starting", providerName: null }, + }); + expect(canFinalizePromotedDraft(preparing)).toBe(false); + expect(canFinalizePromotedDraft({ ...preparing, session: readySession })).toBe(true); + expect(canFinalizePromotedDraft({ ...preparing, latestTurn: completedTurn })).toBe(true); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 5eace49305..76b2de8603 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -233,13 +233,23 @@ export function toolGroupConsumesUpwardNavigation(target: EventTarget | null): b return false; } +export { + findRecordedWorktreeSetup, + resolveVisibleWorktreeSetup, +} from "@t3tools/client-runtime/worktree-setup"; + export function resolveDraftHeroState(input: { isLocalDraftThread: boolean; hasTimelineEntries: boolean; isWorking: boolean; draftHeroDockRequested: boolean; backgroundSubmissionPending: boolean; + /** A worktree setup card is on the timeline, so the timeline must stay visible. */ + hasWorktreeSetupCard?: boolean; }): boolean { + if (input.hasWorktreeSetupCard) { + return false; + } if (input.backgroundSubmissionPending) { return true; } @@ -382,7 +392,7 @@ export function resolveThreadSwitchTimeline(input: export function resolveDraftPromotionNavigationTarget(input: { serverThreadRef: ScopedThreadRef | null; - serverThread: Pick | null | undefined; + serverThread: Pick | null | undefined; backgroundSubmissionPending: boolean; }): ScopedThreadRef | null { if (input.backgroundSubmissionPending) { @@ -392,9 +402,13 @@ export function resolveDraftPromotionNavigationTarget(input: { const turnStarted = input.serverThread?.latestTurn?.startedAt != null; const startupStopped = sessionStatus === "error" || sessionStatus === "stopped" || sessionStatus === "interrupted"; - // Keep local preparation feedback mounted until the server can render the - // running turn or its startup error on the canonical thread route. - return turnStarted || startupStopped ? input.serverThreadRef : null; + // A worktree bootstrap persists the user message before the turn, so the + // thread route can render the send and the live setup by itself. Otherwise + // keep the draft mounted until the server can render the running turn or + // its startup error. + const messagePersisted = + input.serverThread?.messages.some((message) => message.role === "user") ?? false; + return turnStarted || startupStopped || messagePersisted ? input.serverThreadRef : null; } export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { @@ -963,12 +977,24 @@ export function recallCheckoutIsRepo( : sessionCheckoutIsRepo.get(checkoutIsRepoKey(environmentId, cwd)); } -export function threadHasStarted(thread: Thread | null | undefined): boolean { +function threadHasStarted(thread: Thread | null | undefined): boolean { return Boolean( thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null), ); } +/** A preparing session and persisted prompt still need their retry draft. */ +export function canFinalizePromotedDraft(thread: Thread | null | undefined): boolean { + return ( + threadHasStarted(thread) && + !( + thread?.latestTurn === null && + thread.session?.status === "starting" && + thread.session.providerName === null + ) + ); +} + /** * Whether a thread ran at least one turn, judged from its shell alone. * diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d1740f3f69..f27e775cc1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -51,6 +51,7 @@ import { resolveEnvironmentMachineKind, RuntimeMode, TerminalOpenInput, + type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { deriveReportedTurnCosts } from "@t3tools/client-runtime/state/turn-costs"; @@ -331,6 +332,7 @@ import { finalizePromotedDraftThreadByRef, flushComposerDraftStore, markPromotedDraftThreadByRef, + restoreFailedBackgroundDraftThread, useComposerDraftStore, useEffectiveComposerModelState, DraftId, @@ -398,7 +400,7 @@ import { SessionPresentationArea, type SessionInteractionSubmissionState, } from "./chat/ComposerSessionInteractionPanel"; -import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; +import { resolveTimelineIsAtEnd, worktreeSetupAgentStarted } from "./chat/MessagesTimeline.logic"; import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; @@ -501,6 +503,8 @@ import { rememberCheckoutIsRepo, resolveBackgroundDraftWorkspaceOptions, resolveDraftHeroState, + findRecordedWorktreeSetup, + resolveVisibleWorktreeSetup, restorePlanFollowUpComposer, isPaintOnlyThreadTimeline, peekHeldThreadTimeline, @@ -1771,6 +1775,22 @@ export default function ChatView(props: ChatViewProps) { return () => revokeBlobPreviewUrl(src); }, [expandedImage]); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); + // Last live snapshot from the setup stream. The server drops a finished + // snapshot after a grace period and emits null; holding it here bridges the + // gap until the settled activity arrives on the thread projection. + const [heldWorktreeSetup, setHeldWorktreeSetup] = useState(null); + // Set by "Work locally": the draft whose restored message should be resent + // once the cancelled dispatch has settled and the draft is in local mode. + // Keyed by draft id so a bootstrap rotating the thread id keeps it, while + // moving to another draft drops it without an effect. + const [workLocallyResendDraftId, setWorkLocallyResendDraftId] = useState(null); + // The draft route reuses this component across drafts, so a resend recorded + // for one draft must not fire when the user comes back to it later. + useEffect(() => { + if (workLocallyResendDraftId !== null && workLocallyResendDraftId !== draftId) { + setWorkLocallyResendDraftId(null); + } + }, [draftId, workLocallyResendDraftId]); const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< Record> >({}); @@ -3268,7 +3288,7 @@ export default function ChatView(props: ChatViewProps) { resetLocalDispatch, localDispatchStartedAt, latestUserMessageAt, - isPreparingWorktree, + isPreparingWorktree: isLocallyPreparingWorktree, isSendBusy, backgroundSubmissionPending, } = useLocalDispatchState({ @@ -3304,8 +3324,30 @@ export default function ChatView(props: ChatViewProps) { (isSendBusy || phase === "connecting" || phase === "running") && compactRequestIsActive && !compactionSettled; + // The server records a running worktree setup on the thread for the whole + // bootstrap window. That record, with no turn yet, is how a reload or another + // client sees a worktree still being prepared, so it counts as working like + // the local dispatch that started it. It settles on every failure path and + // on restart, so this cannot outlive the setup. The placeholder "starting" + // session is not used here: an ordinary first turn projects one too, and it + // already drives the connecting state on its own. + const recordedWorktreeSetup = useMemo( + () => findRecordedWorktreeSetup(activeThread?.activities ?? [], routeThreadRef.threadId), + [activeThread?.activities, routeThreadRef.threadId], + ); + const awaitingBootstrapTurn = + activeServerThread !== null && + activeServerThread.id === routeThreadRef.threadId && + activeServerThread.latestTurn === null && + recordedWorktreeSetup?.phase === "running"; const isWorking = - phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint || isCompacting; + phase === "running" || + isSendBusy || + isConnecting || + isRevertingCheckpoint || + isCompacting || + awaitingBootstrapTurn; + const isPreparingWorktree = isLocallyPreparingWorktree || awaitingBootstrapTurn; const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, @@ -3625,6 +3667,73 @@ export default function ChatView(props: ChatViewProps) { activeThreadKey, ); const displayedThreadRef = parseScopedThreadKey(displayedTimelineKey); + // Live stages of a bootstrap worktree setup. A worktree send creates the + // server thread under the route's thread id before anything else, so the + // stream is keyed by that id alone: no owner bookkeeping, and a remount, + // reload, or second client picks it up the same way. The subscription is + // held only while a snapshot can still change. + const routeThreadPreparesWorktree = + (isPreparingWorktree && activeThread?.id === routeThreadRef.threadId) || + heldWorktreeSetup?.phase === "running"; + const worktreeSetupQuery = useEnvironmentQuery( + routeThreadPreparesWorktree && + environmentById.get(routeThreadRef.environmentId)?.serverConfig?.environment.capabilities + .worktreeSetupTracking === true + ? vcsEnvironment.worktreeSetup({ + environmentId: routeThreadRef.environmentId, + input: { threadId: routeThreadRef.threadId }, + }) + : null, + ); + const latestWorktreeSetup = worktreeSetupQuery.data; + useEffect(() => { + if (latestWorktreeSetup) setHeldWorktreeSetup(latestWorktreeSetup); + }, [latestWorktreeSetup]); + useEffect(() => { + setHeldWorktreeSetup(null); + }, [routeThreadKey]); + const liveWorktreeSetup = + heldWorktreeSetup?.threadId === routeThreadRef.threadId ? heldWorktreeSetup : null; + const worktreeSetup = resolveVisibleWorktreeSetup({ + live: liveWorktreeSetup, + recorded: recordedWorktreeSetup, + turnStarted: activeThread?.latestTurn?.startedAt != null, + // Counts the optimistic send too, so the row retires the moment the + // follow-up is on screen rather than when the server echoes it back. + followUpSent: timelineMessages.filter((message) => message.role === "user").length > 1, + }); + // Sends wait for the agent handoff, not for the setup script: an async + // script keeps the snapshot running while the agent already works, and a + // follow-up must not be held behind a slow install. Before the first + // snapshot arrives the starting session stands in for it. + const worktreeSetupBlocksSend = + worktreeSetup !== null + ? worktreeSetup.phase === "running" && !worktreeSetupAgentStarted(worktreeSetup) + : isServerThread && + activeThreadShell?.session?.status === "starting" && + activeThreadShell.latestTurn === null; + const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { + reportFailure: false, + }); + const onCancelWorktreeSetup = useCallback(() => { + if (!worktreeSetup || worktreeSetup.phase !== "running") return; + void cancelWorktreeSetup({ + environmentId: routeThreadRef.environmentId, + input: { threadId: worktreeSetup.threadId }, + }); + }, [cancelWorktreeSetup, routeThreadRef.environmentId, worktreeSetup]); + // The setup terminal belongs to the thread that was set up. A failed + // bootstrap deletes that thread and closes its terminals, so only offer the + // terminal while the setup thread is still the active one. + const onOpenWorktreeSetupTerminal = useMemo(() => { + if (!worktreeSetup || !activeThreadRef || worktreeSetup.threadId !== activeThreadRef.threadId) { + return null; + } + const setupThreadRef = activeThreadRef; + return (terminalId: string) => { + storeEnsureTerminal(setupThreadRef, terminalId, { open: true, active: true }); + }; + }, [activeThreadRef, storeEnsureTerminal, worktreeSetup]); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; @@ -3634,6 +3743,9 @@ export default function ChatView(props: ChatViewProps) { isWorking, draftHeroDockRequested, backgroundSubmissionPending, + // A cancelled or failed setup card stays on the draft's timeline; the + // hero headline would paint over it. + hasWorktreeSetupCard: worktreeSetup !== null, }); const [ attachDraftHeroTransitionGroupRef, @@ -8066,6 +8178,7 @@ export default function ChatView(props: ChatViewProps) { } let turnStartSucceeded = false; + let backgroundDraftOpened = false; if (failure === null && turnAttachmentsResult._tag === "Success") { const bootstrap = isLocalDraftThread || baseBranchForWorktree @@ -8126,9 +8239,9 @@ export default function ChatView(props: ChatViewProps) { records: context.records, }), }; - const startResult = + const startPromise = delivery === "follow-up" - ? await followUpThreadInputQueue({ + ? followUpThreadInputQueue({ environmentId, input: { threadId: threadIdForSend, @@ -8142,7 +8255,7 @@ export default function ChatView(props: ChatViewProps) { createdAt: messageCreatedAt, }, }) - : await startThreadTurn({ + : startThreadTurn({ environmentId, input: { threadId: threadIdForSend, @@ -8162,10 +8275,32 @@ export default function ChatView(props: ChatViewProps) { createdAt: messageCreatedAt, }, }); - if (startResult._tag === "Failure") { - if (backgroundThreadRef) { + if (backgroundThreadRef) { + markPromotedDraftThreadByRef(backgroundThreadRef); + try { + backgroundDraftOpened = Boolean( + await handleNewThread( + scopeProjectRef(activeProject.environmentId, activeProject.id), + resolveBackgroundDraftWorkspaceOptions({ + envMode: sendEnvMode, + branch: activeThreadBranch, + startFromOrigin, + }), + ), + ); + } catch (error) { clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Could not open a fresh composer", + description: error instanceof Error ? error.message : undefined, + }), + ); } + } + const startResult = await startPromise; + if (startResult._tag === "Failure") { failure = startResult; } else { turnStartSucceeded = true; @@ -8174,48 +8309,26 @@ export default function ChatView(props: ChatViewProps) { } acknowledgeActiveThreadWoke(); if (backgroundThreadRef) { - markPromotedDraftThreadByRef(backgroundThreadRef); - try { - const nextDraft = await handleNewThread( - scopeProjectRef(activeProject.environmentId, activeProject.id), - resolveBackgroundDraftWorkspaceOptions({ - envMode: sendEnvMode, - branch: activeThreadBranch, - startFromOrigin, - }), - ); - if (nextDraft) { - finalizePromotedDraftThreadByRef(backgroundThreadRef); - toastManager.add( - stackedThreadToast({ - type: "success", - title: "Started in background", - timeout: 5_000, - actionProps: { - children: "Open", - onClick: () => { - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(backgroundThreadRef), - }); - }, - }, - }), - ); - } else { - clearBackgroundDraftSubmissionByRef(backgroundThreadRef); - } - } catch (error) { + if (backgroundDraftOpened || currentRouteThreadKeyRef.current !== routeThreadKey) { + finalizePromotedDraftThreadByRef(backgroundThreadRef); + } else { clearBackgroundDraftSubmissionByRef(backgroundThreadRef); - resetLocalDispatch(); + } + if (backgroundDraftOpened) { toastManager.add( stackedThreadToast({ - type: "warning", - title: "Task started in the background", - description: - error instanceof Error - ? `Could not open a fresh composer: ${error.message}` - : "Could not open a fresh composer.", + type: "success", + title: "Started in background", + timeout: 5_000, + actionProps: { + children: "Open", + onClick: () => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(backgroundThreadRef), + }); + }, + }, }), ); } @@ -8224,6 +8337,16 @@ export default function ChatView(props: ChatViewProps) { } if (failure !== null) { + if (resolvedSubmissionIntent === "background" && draftId && draftThread) { + restoreFailedBackgroundDraftThread( + draftId, + draftThread, + wasBootstrapThreadDeleted(squashAtomCommandFailure(failure)) + ? newThreadId() + : threadIdForSend, + ); + clearBackgroundDraftSubmissionByRef(scopeThreadRef(environmentId, threadIdForSend)); + } const removeOptimisticMessage = () => setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -8236,24 +8359,32 @@ export default function ChatView(props: ChatViewProps) { removeOptimisticMessage(); const currentDraft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) ?? null; + const recoveryOwnsComposer = + !backgroundDraftOpened && currentRouteThreadKeyRef.current === routeThreadKey; const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); const mergedPromptAndImages = mergeFailedComposerSend({ failedText: messageTextForSend, - currentText: promptRef.current, + currentText: recoveryOwnsComposer ? promptRef.current : (currentDraft?.prompt ?? ""), failedAttachments: retryComposerImages, - currentAttachments: composerImagesRef.current, + currentAttachments: recoveryOwnsComposer + ? composerImagesRef.current + : (currentDraft?.images ?? []), }); const mergedFiles = mergeFailedComposerSend({ failedText: "", currentText: "", failedAttachments: composerFilesSnapshot, - currentAttachments: composerFilesRef.current, + currentAttachments: recoveryOwnsComposer + ? composerFilesRef.current + : (currentDraft?.files ?? []), }).attachments; const mergedTerminalContexts = mergeFailedComposerSend({ failedText: "", currentText: "", failedAttachments: composerTerminalContextsSnapshot, - currentAttachments: composerTerminalContextsRef.current, + currentAttachments: recoveryOwnsComposer + ? composerTerminalContextsRef.current + : (currentDraft?.terminalContexts ?? []), }).attachments; const mergedPreviewAnnotations = mergeFailedComposerSend({ failedText: "", @@ -8268,10 +8399,12 @@ export default function ChatView(props: ChatViewProps) { currentAttachments: currentDraft?.reviewComments ?? [], }).attachments; const mergedPrompt = mergedPromptAndImages.text; - promptRef.current = mergedPrompt; - composerImagesRef.current = mergedPromptAndImages.attachments; - composerFilesRef.current = mergedFiles; - composerTerminalContextsRef.current = mergedTerminalContexts; + if (recoveryOwnsComposer) { + promptRef.current = mergedPrompt; + composerImagesRef.current = mergedPromptAndImages.attachments; + composerFilesRef.current = mergedFiles; + composerTerminalContextsRef.current = mergedTerminalContexts; + } clearComposerDraftContent(composerDraftTarget); setComposerDraftPrompt(composerDraftTarget, mergedPrompt); addComposerDraftImages(composerDraftTarget, mergedPromptAndImages.attachments); @@ -8279,14 +8412,21 @@ export default function ChatView(props: ChatViewProps) { setComposerDraftTerminalContexts(composerDraftTarget, mergedTerminalContexts); setComposerDraftPreviewAnnotations(composerDraftTarget, mergedPreviewAnnotations); setComposerDraftReviewComments(composerDraftTarget, mergedReviewComments); - composerRef.current?.resetCursorState({ - cursor: collapseExpandedComposerCursor(mergedPrompt, mergedPrompt.length), - prompt: mergedPrompt, - detectTrigger: true, - }); + if (recoveryOwnsComposer) { + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(mergedPrompt, mergedPrompt.length), + prompt: mergedPrompt, + detectTrigger: true, + }); + } if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); - if (isLocalDraftThread && draftId && wasBootstrapThreadDeleted(error)) { + if ( + resolvedSubmissionIntent !== "background" && + isLocalDraftThread && + draftId && + wasBootstrapThreadDeleted(error) + ) { const failedDraftSession = getDraftSession(draftId); if (failedDraftSession?.threadId === threadIdForSend) { setLogicalProjectDraftThreadId( @@ -8298,6 +8438,9 @@ export default function ChatView(props: ChatViewProps) { createdAt: new Date().toISOString(), }, ); + if (currentRouteThreadKeyRef.current === routeThreadKey) { + void navigate({ to: "/draft/$draftId", params: { draftId }, replace: true }); + } } } setThreadError( @@ -8308,6 +8451,21 @@ export default function ChatView(props: ChatViewProps) { ? "Failed to queue follow-up." : "Failed to send message.", ); + if (backgroundDraftOpened && draftId) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Background task failed", + description: error instanceof Error ? error.message : "Failed to send message.", + actionProps: { + children: "Open draft", + onClick: () => { + void navigate({ to: "/draft/$draftId", params: { draftId } }); + }, + }, + }), + ); + } } } if (turnStartSucceeded) { @@ -9713,6 +9871,70 @@ export default function ChatView(props: ChatViewProps) { ], ); + // "Work locally" on the setup card: cancel the bootstrap and remember the + // draft. The cancelled dispatch deletes the half-made thread and puts the + // message back in the composer; the effect below then flips the draft to + // local mode and resends. The draft is a server thread for the whole + // setup (the bootstrap created it), so this keys off the route, not + // `isLocalDraftThread`. + const onWorktreeSetupWorkLocally = useCallback(() => { + if (!worktreeSetup || worktreeSetup.phase !== "running" || !draftId) { + return; + } + const target = { + environmentId: routeThreadRef.environmentId, + input: { threadId: worktreeSetup.threadId }, + }; + void (async () => { + const result = await cancelWorktreeSetup(target); + if (result._tag !== "Success" || !result.value.cancelled) return; + setWorkLocallyResendDraftId(draftId); + })(); + }, [cancelWorktreeSetup, draftId, routeThreadRef.environmentId, worktreeSetup]); + const onSendRef = useRef(onSend); + onSendRef.current = onSend; + // Resend once the cancelled dispatch has settled and the composer is free. + // Every state that makes `onSend` bail and wait is part of the readiness + // check, so the flag survives a reconnect, a reverting checkpoint, or a + // feedback upload in between. What remains inside `onSend` are the checks + // that need the user to change something, and those should not auto retry. + const workLocallyResendReady = + workLocallyResendDraftId !== null && + workLocallyResendDraftId === draftId && + isLocalDraftThread && + !isSendBusy && + !isConnecting && + !isRevertingCheckpoint && + !threadDetailLoading && + clientSettingsHydrated && + !needsLoadBalancing && + !activeEnvironmentUnavailable && + !activePendingProgress && + !feedbackUploading; + useEffect(() => { + if ( + !workLocallyResendReady || + sendInFlightRef.current || + feedbackUploadsInFlightRef.current.has(routeThreadKey) + ) { + return; + } + if (sendEnvMode !== "local") { + // The draft is back; switch it to the project checkout and let the next + // render resend. + setDraftThreadContext(composerDraftTarget, { envMode: "local", startFromOrigin: false }); + return; + } + setWorkLocallyResendDraftId(null); + void onSendRef.current(); + }, [ + composerDraftTarget, + routeThreadKey, + sendEnvMode, + setDraftThreadContext, + workLocallyResendReady, + ]); + const onStartFromOriginChange = (nextStartFromOrigin: boolean) => { if (canOverrideServerThreadEnvMode && activeThread) { setPendingServerThreadStartFromOriginByThreadId((current) => @@ -10193,6 +10415,10 @@ export default function ChatView(props: ChatViewProps) { isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree} isCompacting={!paintOnlyDisplayedTimeline && isCompacting} activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt} + worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup} + onCancelWorktreeSetup={onCancelWorktreeSetup} + {...(draftId ? { onWorktreeSetupWorkLocally } : {})} + {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})} listRef={legendListRef} timelineEntries={displayedTimeline.entries} latestTurn={paintOnlyDisplayedTimeline ? null : activeLatestTurn} @@ -10381,11 +10607,13 @@ export default function ChatView(props: ChatViewProps) { ? "Sending feedback" : threadDetailLoading ? "Messages loading" - : projectCloneSendBlockReason - ? projectCloneSendBlockReason - : activeSessionInteraction - ? "Resolve the session request to continue" - : providerAdmissionDisabledReason + : worktreeSetupBlocksSend + ? "Preparing worktree" + : projectCloneSendBlockReason + ? projectCloneSendBlockReason + : activeSessionInteraction + ? "Resolve the session request to continue" + : providerAdmissionDisabledReason } isPreparingWorktree={isPreparingWorktree} bannerItems={composerBannerItems} diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 304922909b..15cca412ba 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -113,6 +113,7 @@ export default function ProjectScriptsControl({ command: fileScript.command, icon: fileScript.icon ?? "play", runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, + waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false, keybinding: null, previewUrl: fileScript.previewUrl ?? null, autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx new file mode 100644 index 0000000000..71401b539b --- /dev/null +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -0,0 +1,214 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; + +import ChatView from "./ChatView"; +import { resolveDraftPromotionNavigationTarget, canFinalizePromotedDraft } from "./ChatView.logic"; +import { waitForDraftHeroTransition } from "./chat/draftHeroTransition"; +import { SidebarInset } from "./ui/sidebar"; +import { + finalizePromotedDraftThreadByRef, + markPromotedDraftThreadByRef, + useBackgroundDraftSubmissionPending, + useComposerDraftStore, +} from "../composerDraftStore"; +import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; +import { + useEnvironmentThreadRefs, + useThread, + useThreadDetail, + useThreadRefs, + useThreadShell, + useThreadStatus, +} from "../state/entities"; +import { useEnvironmentQuery } from "../state/query"; +import { environmentShell } from "../state/shell"; +import { + buildThreadRouteParams, + resolveThreadRouteRenderState, + type ThreadRouteTarget, +} from "../threadRoutes"; +import { resolveThreadSyncPhase } from "../threadSync"; + +/** + * The single chat surface behind both `/draft/$draftId` and + * `/$environmentId/$threadId`. Each draft gets its own ChatView instance (so + * a background send's state stays with the draft it came from), and that + * instance carries the draft through its promotion to a server thread: the + * thread route keeps keying by the draft id while the draft record exists, + * so the route swap only changes props and the timeline never paints an + * empty frame. Plain server threads are unkeyed, so navigating between them + * reuses one instance as ChatView expects. + * + * Rendered by the `_chat` layout rather than by the two leaf routes, since + * an element only survives a route swap when the same parent renders it. + */ +export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { + const navigate = useNavigate(); + const draftId = target.kind === "draft" ? target.draftId : null; + const draftSession = useComposerDraftStore((store) => + draftId === null ? null : store.getDraftSession(draftId), + ); + const threadRefs = useThreadRefs(); + // The server thread this view is about: the route's own ref, or the draft's + // reserved ref once the server knows it. + const inferredThreadRef = draftSession + ? (threadRefs.find( + (ref) => + ref.environmentId === draftSession.environmentId && + ref.threadId === draftSession.threadId, + ) ?? null) + : null; + const serverThreadRef: ScopedThreadRef | null = + target.kind === "server" ? target.threadRef : (draftSession?.promotedTo ?? inferredThreadRef); + const serverThread = useThread(serverThreadRef); + const backgroundSubmissionPending = useBackgroundDraftSubmissionPending( + target.kind === "draft" ? serverThreadRef : null, + ); + const canonicalThreadRef = + target.kind === "draft" + ? resolveDraftPromotionNavigationTarget({ + serverThreadRef, + serverThread, + backgroundSubmissionPending, + }) + : null; + + const shell = useEnvironmentQuery( + serverThreadRef === null ? null : environmentShell.stateAtom(serverThreadRef.environmentId), + ); + const serverThreadShell = useThreadShell(serverThreadRef); + const serverThreadDetail = useThreadDetail(serverThreadRef); + const serverThreadStatus = useThreadStatus(serverThreadRef); + const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null); + const bootstrapComplete = shell.data?.snapshot._tag === "Some"; + const draftThread = useComposerDraftStore((store) => + serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null, + ); + const promotedDraftId = useComposerDraftStore((store) => + target.kind === "server" ? store.getDraftIdByRef(target.threadRef) : null, + ); + // The draft record is removed once the promoted thread has started, which + // is after the route swap. Latch the key so the element that carried the + // draft keeps its identity for as long as this thread stays on screen. + const [chatViewKey, setChatViewKey] = useState<{ threadKey: string; key: string } | null>(null); + const serverThreadKey = target.kind === "server" ? scopedThreadKey(target.threadRef) : null; + const nextChatViewKey = + serverThreadKey === null + ? null + : chatViewKey?.threadKey === serverThreadKey + ? chatViewKey + : promotedDraftId + ? { threadKey: serverThreadKey, key: promotedDraftId } + : null; + if (nextChatViewKey !== chatViewKey) { + setChatViewKey(nextChatViewKey); + } + const environmentHasDraftThreads = useComposerDraftStore((store) => + serverThreadRef ? store.hasDraftThreadsInEnvironment(serverThreadRef.environmentId) : false, + ); + const renderState = resolveThreadRouteRenderState({ + bootstrapComplete, + serverThreadShellExists: serverThreadShell !== null, + serverThreadDetailExists: serverThreadDetail !== null, + serverThreadDetailDeleted: serverThreadStatus === "deleted", + draftThreadExists: draftThread !== null, + }); + const threadSyncPhase = resolveThreadSyncPhase({ + detailExists: serverThreadDetail !== null, + shellExists: serverThreadShell !== null, + status: serverThreadStatus, + }); + const serverThreadStarted = canFinalizePromotedDraft(serverThreadDetail); + const environmentHasAnyThreads = environmentThreadRefs.length > 0 || environmentHasDraftThreads; + + useEffect(() => { + if (!inferredThreadRef || draftSession?.promotedTo) { + return; + } + markPromotedDraftThreadByRef(inferredThreadRef); + }, [draftSession?.promotedTo, inferredThreadRef]); + + useEffect(() => { + if (!canonicalThreadRef) { + return; + } + let cancelled = false; + void waitForDraftHeroTransition().then(() => { + if (cancelled) { + return; + } + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(canonicalThreadRef), + replace: true, + }); + }); + return () => { + cancelled = true; + }; + }, [canonicalThreadRef, navigate]); + + useEffect(() => { + if (target.kind !== "draft" || draftSession || canonicalThreadRef) { + return; + } + void navigate({ to: "/", replace: true }); + }, [canonicalThreadRef, draftSession, navigate, target.kind]); + + useEffect(() => { + if (target.kind !== "server" || !bootstrapComplete) { + return; + } + // Navigation already resolved onto this path, so a drop aimed here + // passed its landing check; once the thread reads as missing it can + // never be attached, release it even when there is nowhere to redirect. + if (renderState === "missing") { + const { clearPendingFileDropsForThread } = useSidebarPendingFileDropStore.getState(); + clearPendingFileDropsForThread(target.threadRef); + if (environmentHasAnyThreads) { + void navigate({ to: "/", replace: true }); + } + } + }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, target]); + + useEffect(() => { + if (target.kind !== "server" || !serverThreadStarted || !draftThread) { + return; + } + finalizePromotedDraftThreadByRef(target.threadRef); + }, [draftThread, serverThreadStarted, target]); + + let view: React.ReactNode = null; + if (target.kind === "draft") { + if (draftSession) { + view = ( + + ); + } + } else if (renderState === "ready" || (renderState === "loading" && serverThreadShell !== null)) { + view = ( + + ); + } + + return ( + + {view} + + ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 0b1fd796f6..7011b248f4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -10,6 +10,7 @@ import { ThreadId, TurnId, type OrchestrationThread, + type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { applyThreadDetailEvent, @@ -32,6 +33,7 @@ import { shouldPreserveAssistantLineBreaks, type MessagesTimelineRow, type MessagesTimelineRowsProjection, + WORKTREE_SETUP_ROW_ID, workEntryDisplayLabel, } from "./MessagesTimeline.logic"; import { @@ -1266,6 +1268,196 @@ describe("resolveAssistantMessageCopyState", () => { }); describe("deriveMessagesTimelineRows", () => { + it("leads the worktree setup card with the working header", () => { + const snapshot: WorktreeSetupSnapshot = { + threadId: ThreadId.make("thread-setup"), + phase: "running", + startedAt: "2026-01-01T00:00:00Z", + endedAt: null, + branch: "feature", + baseRef: "main", + worktreePath: null, + setupScript: null, + stages: [], + error: null, + sequence: 3, + }; + const userEntry = { + id: "user-entry", + kind: "message", + createdAt: "2026-01-01T00:00:00Z", + message: { + id: "user-1" as never, + role: "user", + text: "Build it", + turnId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + } as const; + const assistantEntry = { + id: "assistant-entry", + kind: "message", + createdAt: "2026-01-01T00:00:30Z", + message: { + id: "assistant-1" as never, + role: "assistant", + text: "On it", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", + streaming: true, + }, + } as const; + const withoutMessages = deriveMessagesTimelineRows({ + timelineEntries: [], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: snapshot, + }); + expect(withoutMessages).toEqual([ + { kind: "working", id: "working-indicator-row", createdAt: "2026-01-01T00:00:00Z" }, + { + kind: "worktree-setup", + id: WORKTREE_SETUP_ROW_ID, + createdAt: "2026-01-01T00:00:00Z", + snapshot, + embedded: false, + }, + ]); + + // The main pass already places the working header after the send while a + // bootstrap counts as working; the card slots under that one header. + const withUserMessage = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: snapshot, + }); + expect(withUserMessage.map((row) => row.kind)).toEqual([ + "message", + "working", + "worktree-setup", + ]); + + // A failed setup never handed off, so the card stays under the send. The + // rest of the timeline is untouched: a running send still gets its + // working and thinking rows. + const withMessages = deriveMessagesTimelineRows({ + timelineEntries: [userEntry, assistantEntry], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: { ...snapshot, phase: "failed" }, + }); + expect(withMessages.map((row) => row.kind)).toEqual([ + "message", + "worktree-setup", + "working", + "message", + "thinking", + ]); + const runningSetup = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: snapshot, + }); + expect(runningSetup.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]); + + // Once the agent stage is done and the turn is live, a still-running + // script leaves the timeline; the working header surfaces it instead. + const stage = (id: "agent" | "setup-script", status: "done" | "running") => + ({ + id, + status, + startedAt: "2026-01-01T00:00:10Z", + endedAt: status === "done" ? "2026-01-01T00:00:11Z" : null, + percent: null, + detail: null, + tail: [], + }) as const; + const asyncSnapshot: WorktreeSetupSnapshot = { + ...snapshot, + stages: [stage("setup-script", "running"), stage("agent", "done")], + }; + const liveTurn = { + turnId: "turn-1" as never, + state: "running", + startedAt: "2026-01-01T00:00:11Z", + completedAt: null, + } as const; + const asyncRows = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + latestTurn: liveTurn, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: asyncSnapshot, + }); + expect(asyncRows.map((row) => row.kind)).toEqual(["message", "working", "thinking"]); + + // Dispatched but not yet visible as a turn: the full card stays put so + // nothing collapses during the handoff. + const handoffRows = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: asyncSnapshot, + }); + expect(handoffRows.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]); + expect(handoffRows[2]).toMatchObject({ kind: "worktree-setup", embedded: false }); + + // A script that outlives the reply never trails the assistant's message. + const outlivedRows = deriveMessagesTimelineRows({ + timelineEntries: [ + userEntry, + { ...assistantEntry, message: { ...assistantEntry.message, streaming: false } }, + ], + latestTurn: { ...liveTurn, state: "completed", completedAt: "2026-01-01T00:00:40Z" }, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: asyncSnapshot, + }); + expect(outlivedRows.map((row) => row.kind)).toEqual(["message", "message"]); + + // A failed script after the handoff keeps its row under the send. + const failedRows = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + latestTurn: liveTurn, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: { + ...asyncSnapshot, + phase: "done", + endedAt: "2026-01-01T00:00:20Z", + stages: [{ ...stage("setup-script", "done"), status: "failed" }, stage("agent", "done")], + }, + }); + expect(failedRows.map((row) => row.kind)).toEqual([ + "message", + "worktree-setup", + "working", + "thinking", + ]); + expect(failedRows[1]).toMatchObject({ kind: "worktree-setup", embedded: true }); + }); + it("keeps context compaction visible outside folded work", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 42aa59f9bd..cde317b129 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,4 +1,6 @@ import { delegationActivity } from "../../delegationActivity"; +import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; +export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import * as Equal from "effect/Equal"; import { shallow } from "zustand/vanilla/shallow"; import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; @@ -30,7 +32,12 @@ import { import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; import { deriveRollbackTargets } from "@t3tools/client-runtime/rollback"; import { formatReportedTurnCost } from "@t3tools/client-runtime/state/turn-costs"; -import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts"; +import { + type MessageId, + type OrchestrationLatestTurn, + type TurnId, + type WorktreeSetupSnapshot, +} from "@t3tools/contracts"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; const TIMELINE_MINIMAP_ITEM_SPACING = 8; @@ -393,6 +400,18 @@ export type MessagesTimelineRow = kind: "thinking"; id: string; createdAt: string | null; + } + | { + kind: "worktree-setup"; + id: string; + createdAt: string | null; + snapshot: WorktreeSetupSnapshot; + /** + * The agent's turn is live and owns the "Working for" header, so the + * card drops its own header and settle-time actions. The stage list + * stays put so nothing jumps when the handoff happens. + */ + embedded: boolean; }; export interface StableMessagesTimelineRowsState { @@ -847,6 +866,8 @@ export function deriveMessagesTimelineRows(input: { supportsConversationRollback: boolean; /** Client-only messages (optimistic sends, feedback transcripts); never rollback anchors. */ localMessageIds?: ReadonlySet; + /** Live bootstrap progress. Renders a stage card under the first user message. */ + worktreeSetup?: WorktreeSetupSnapshot | null; }): MessagesTimelineRow[] { const turnDiffSummaryByAssistantMessageId = new Map(); for (const summary of input.turnDiffSummaries) { @@ -1240,10 +1261,63 @@ export function deriveMessagesTimelineRows(input: { }); } - if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { + // Until the agent's turn is live, the setup card sits under the send with + // the working header above it (the header reads "Setting up worktree…" and + // later swaps its text in place, so nothing moves at the handoff). "Live" + // means the turn is in the timeline, not just that the server dispatched + // it: the card must not vanish in the gap between. Once the turn is live + // the stage list leaves the timeline; a script that is still running is + // surfaced by the working header itself. A failed or cancelled setup stays + // under the send so its outcome and actions remain reachable. + const setupHandedOff = + input.worktreeSetup !== null && + input.worktreeSetup !== undefined && + worktreeSetupAgentStarted(input.worktreeSetup) && + input.latestTurn?.startedAt != null; + const setupRunning = !setupHandedOff && input.worktreeSetup?.phase === "running"; + if (input.worktreeSetup && (!setupHandedOff || input.worktreeSetup.phase !== "running")) { + const setupRow = { + kind: "worktree-setup", + id: WORKTREE_SETUP_ROW_ID, + createdAt: input.worktreeSetup.startedAt, + snapshot: input.worktreeSetup, + embedded: setupHandedOff, + } as const; + const firstUserRowIndex = nextRows.findIndex( + (row) => row.kind === "message" && row.message.role === "user", + ); + // While the setup runs, the working header leads the card in the same + // slot it keeps once the agent's own turn takes over. The main pass may + // already have placed that header (a bootstrap counts as working). + const workingRowIndex = setupRunning ? nextRows.findIndex((row) => row.kind === "working") : -1; + if (workingRowIndex >= 0) { + nextRows.splice(workingRowIndex + 1, 0, setupRow); + } else { + const insertAt = firstUserRowIndex >= 0 ? firstUserRowIndex + 1 : nextRows.length; + nextRows.splice( + insertAt, + 0, + ...(setupRunning + ? [ + { + kind: "working", + id: "working-indicator-row", + createdAt: input.worktreeSetup.startedAt, + } as const, + setupRow, + ] + : [setupRow]), + ); + } + } + + // A running setup owns the working slot above its card and shows no + // activity row of its own; every other state gets the usual tail. + const hasWorkingRow = nextRows.some((row) => row.kind === "working"); + if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } - if (input.isWorking && (!hasActivityRow || latestToolFailed)) { + if (input.isWorking && !setupRunning && (!hasActivityRow || latestToolFailed)) { nextRows.push({ kind: "thinking", id: LIVE_ACTIVITY_ROW_ID, @@ -1254,6 +1328,8 @@ export function deriveMessagesTimelineRows(input: { return attachTrailingToolGroupsToAssistant(nextRows); } +export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row"; + type MessagesTimelineRowsInput = Parameters[0]; export interface MessagesTimelineRowsProjection { @@ -1356,6 +1432,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "working": case "thinking": return a.createdAt === (b as typeof a).createdAt; + case "worktree-setup": + return a.snapshot === (b as typeof a).snapshot; case "assistant-meta": { const bm = b as typeof a; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 8195e62f0c..ab07bc3a11 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -2090,7 +2090,7 @@ describe("MessagesTimeline", () => { // pins it to h-6 so swapping between "Working for" and the worktree-setup // label cannot change the row's height. expect(markup).toContain( - 'class="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums"', + 'class="flex h-6 min-w-0 items-baseline gap-2 px-1 text-sm leading-relaxed text-muted-foreground tabular-nums"', ); expect(markup).not.toContain('class="pt-0.5 pb-5 pl-1.5"'); expect(markup).not.toContain('data-slot="dot-matrix"'); @@ -2114,7 +2114,7 @@ describe("MessagesTimeline", () => { // matching the working row's h-6, which is what this asserts. expect(markup).toContain("gap-1.5 py-0.5 px-0.5"); expect(markup).toContain("flex size-6 shrink-0 items-center justify-center"); - expect(markup).toContain("flex h-6 min-w-0 items-baseline px-1"); + expect(markup).toContain("flex h-6 min-w-0 items-baseline gap-2 px-1"); }); it("renders review comment contexts as structured cards instead of raw tags", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 0f7a4f9273..a13ad11e7b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -25,6 +25,7 @@ import { type ServerProviderSkill, type ToolActivityIcon, type TurnId, + type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; @@ -184,6 +185,7 @@ import { toolGroupAction, workEntryDisplayLabel, workEntryIsVisibleInGroup, + worktreeSetupAgentStarted, type StableMessagesTimelineRowsState, type MessagesTimelineRow, TIMELINE_MINIMAP_MIN_ITEMS, @@ -191,7 +193,10 @@ import { type WorkGroupScrollAnchor, } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Spinner } from "../ui/spinner"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { WorktreeSetupCard } from "./WorktreeSetupCard"; import { ContextChipPopover as UserMessageContextPopover, ContextChipShell, @@ -279,6 +284,9 @@ interface TimelineRowSharedState { delegatedThreads: DelegatedThreadRows; delegationWaitingTurnId: TurnId | null; onOpenAgents: () => void; + onCancelWorktreeSetup: (() => void) | null; + onWorktreeSetupWorkLocally: (() => void) | null; + onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null; } interface TimelineRowActivityState { @@ -290,6 +298,12 @@ interface TimelineRowActivityState { latestTurnId: TurnId | null; /** Current plan step for the working row, when the turn has a plan. */ workingStepLabel: string | null; + /** + * A worktree setup whose script is still running after the agent took + * over. The working header shows it as a chip with a popover; the stage + * list itself has already left the timeline. + */ + backgroundWorktreeSetup: WorktreeSetupSnapshot | null; } const TimelineRowCtx = createContext(null!); @@ -378,6 +392,11 @@ interface MessagesTimelineProps { isPreparingWorktree?: boolean; isCompacting?: boolean; activeTurnStartedAt: string | null; + /** Live bootstrap progress for this thread, or null when none is tracked. */ + worktreeSetup?: WorktreeSetupSnapshot | null; + onCancelWorktreeSetup?: () => void; + onWorktreeSetupWorkLocally?: () => void; + onOpenWorktreeSetupTerminal?: (terminalId: string) => void; listRef: React.RefObject; timelineEntries: ReturnType; latestTurn: TimelineLatestTurn | null; @@ -444,6 +463,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, workingStepLabel = null, activeTurnInProgress, + worktreeSetup = null, + onCancelWorktreeSetup, + onWorktreeSetupWorkLocally, + onOpenWorktreeSetupTerminal, isPreparingWorktree = false, isCompacting = false, activeTurnStartedAt, @@ -668,6 +691,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ...(reportedTurnCosts === undefined ? {} : { reportedTurnCosts }), supportsConversationRollback, ...(localMessageIds === undefined ? {} : { localMessageIds }), + worktreeSetup, }, previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot ? previous.projection @@ -690,6 +714,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ reportedTurnCosts, supportsConversationRollback, localMessageIds, + worktreeSetup, ]); const rows = useStableRows(rawRows, listIdentityKey); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); @@ -1042,6 +1067,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ delegatedThreads, delegationWaitingTurnId, onOpenAgents, + onCancelWorktreeSetup: onCancelWorktreeSetup ?? null, + onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null, + onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null, }), [ readyCitationRequest, @@ -1069,8 +1097,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({ delegatedThreads, delegationWaitingTurnId, onOpenAgents, + onCancelWorktreeSetup, + onWorktreeSetupWorkLocally, + onOpenWorktreeSetupTerminal, ], ); + const backgroundWorktreeSetup = + worktreeSetup !== null && + worktreeSetup.phase === "running" && + worktreeSetupAgentStarted(worktreeSetup) && + latestTurn?.startedAt != null + ? worktreeSetup + : null; const activityState = useMemo( () => ({ isWorking, @@ -1080,9 +1118,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ activeTurnInProgress, latestTurnId: latestTurn?.turnId ?? null, workingStepLabel, + backgroundWorktreeSetup, }), [ activeTurnInProgress, + backgroundWorktreeSetup, isCompacting, isRevertingCheckpoint, isWorking, @@ -1535,7 +1575,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time row.kind === "work" || row.kind === "work-live" || row.kind === "work-toggle" || - row.kind === "thinking" + row.kind === "thinking" || + row.kind === "worktree-setup" ? "pb-2" : "pb-4", (row.kind === "message" && row.message.role === "assistant") || @@ -1570,10 +1611,36 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "proposed-plan" ? : null} {row.kind === "working" ? : null} {row.kind === "thinking" ? : null} + {row.kind === "worktree-setup" ? : null} ); }); +function WorktreeSetupTimelineRow({ + row, +}: { + row: Extract; +}) { + const ctx = use(TimelineRowCtx); + const terminalId = row.snapshot.setupScript?.terminalId ?? null; + const openTerminal = ctx.onOpenWorktreeSetupTerminal; + const onOpenTerminal = useMemo( + () => (openTerminal && terminalId ? () => openTerminal(terminalId) : null), + [openTerminal, terminalId], + ); + return ( + + ); +} + function ContextCompactionTimelineRow({ row, }: { @@ -2130,45 +2197,86 @@ function ProposedPlanTimelineRow({ } function WorkingTimelineRow({ row }: { row: Extract }) { - const { workingStepLabel, isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); + const { workingStepLabel, isCompacting, isPreparingWorktree, backgroundWorktreeSetup } = + use(TimelineRowActivityCtx); + // One span for every label so the setup-to-working handoff swaps text in + // place instead of remounting the row. + const shimmer = isPreparingWorktree || isCompacting; + const label = isPreparingWorktree ? ( + "Setting up worktree…" + ) : isCompacting ? ( + + ) : row.createdAt ? ( + <> + Working for + + ) : ( + "Working..." + ); return (
-
+
- {isPreparingWorktree ? ( - <> - Setting up worktree… - Setting up worktree… - - ) : isCompacting ? ( - <> - - - - - - ) : row.createdAt ? ( - <> - Working for - - ) : ( - "Working..." - )} + {label} + {shimmer ? {label} : null} {workingStepLabel ? ( · {workingStepLabel} ) : null} + {backgroundWorktreeSetup ? ( + + ) : null}
); } +/** + * Trailing chip in the working header while a setup script still runs after + * the agent started. Opens the stage list and live output in a popover; the + * chip leaves with the script, so nothing lingers in the timeline. + */ +function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnapshot }) { + const ctx = use(TimelineRowCtx); + const terminalId = snapshot.setupScript?.terminalId ?? null; + const openTerminal = ctx.onOpenWorktreeSetupTerminal; + const onOpenTerminal = useMemo( + () => (openTerminal && terminalId ? () => openTerminal(terminalId) : null), + [openTerminal, terminalId], + ); + const scriptName = snapshot.setupScript?.name ?? "Setup script"; + return ( + + + } + > + + {scriptName} + + + + + + ); +} + function ThinkingTimelineRow() { const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); // Reserve the activity row during setup so the handoff keeps the same height. diff --git a/apps/web/src/components/chat/WorktreeSetupCard.tsx b/apps/web/src/components/chat/WorktreeSetupCard.tsx new file mode 100644 index 0000000000..49ed6ec401 --- /dev/null +++ b/apps/web/src/components/chat/WorktreeSetupCard.tsx @@ -0,0 +1,444 @@ +import { + worktreeSetupStageLabel, + type WorktreeSetupSnapshot, + type WorktreeSetupStage, +} from "@t3tools/contracts"; +import { formatDuration } from "@t3tools/shared/orchestrationTiming"; +import { + CheckIcon, + ChevronDownIcon, + ChevronRightIcon, + CircleAlertIcon, + CircleIcon, + LaptopIcon, + MinusIcon, + TerminalIcon, + XIcon, +} from "lucide-react"; +import { useEffect, useState, type ReactNode } from "react"; + +import { Button } from "~/components/ui/button"; +import { Spinner } from "~/components/ui/spinner"; +import { observeVisibleAnimation } from "~/lib/visibleAnimation"; +import { cn } from "~/lib/utils"; + +interface WorktreeSetupCardProps { + snapshot: WorktreeSetupSnapshot; + /** Interrupts the server-side bootstrap. Hidden once the setup has settled. */ + onCancel: (() => void) | null; + /** Restarts the same message in the project checkout instead of a worktree. */ + onWorkLocally: (() => void) | null; + /** Reveals the setup script terminal tab. Null when no script ran. */ + onOpenTerminal: (() => void) | null; +} + +function stageElapsedMs(stage: WorktreeSetupStage, nowMs: number): number | null { + if (!stage.startedAt) return null; + const start = Date.parse(stage.startedAt); + const end = stage.endedAt ? Date.parse(stage.endedAt) : nowMs; + if (!Number.isFinite(start) || !Number.isFinite(end)) return null; + return Math.max(0, end - start); +} + +/** + * Ticks once a second while any stage runs so elapsed labels stay live + * without pushing a React commit through the timeline for every second. + */ +function useNowWhile(active: boolean): number { + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + if (!active) return; + let timer: ReturnType | undefined; + const update = () => { + if (timer !== undefined) clearInterval(timer); + timer = undefined; + if (document.visibilityState !== "visible" || !document.hasFocus()) return; + setNowMs(Date.now()); + timer = setInterval(() => setNowMs(Date.now()), 1_000); + }; + update(); + document.addEventListener("visibilitychange", update); + window.addEventListener("focus", update); + window.addEventListener("blur", update); + return () => { + if (timer !== undefined) clearInterval(timer); + document.removeEventListener("visibilitychange", update); + window.removeEventListener("focus", update); + window.removeEventListener("blur", update); + }; + }, [active]); + return nowMs; +} + +function StageIcon({ status }: { status: WorktreeSetupStage["status"] }) { + const className = "size-4 shrink-0 stroke-[1.8]"; + switch (status) { + case "done": + return ; + case "running": + return ; + case "failed": + return ; + case "warning": + return ; + case "skipped": + return ; + case "pending": + return ; + } +} + +function stageRowClassName(status: WorktreeSetupStage["status"]): string { + switch (status) { + case "failed": + return "text-destructive-foreground"; + case "warning": + return "text-warning-foreground"; + case "pending": + return "text-secondary-label opacity-40"; + case "running": + case "skipped": + case "done": + return "text-secondary-label"; + } +} + +/** Same shimmer treatment as the live tool rows in the timeline. */ +function ShimmerOverlay({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} + +function headerLabel(snapshot: WorktreeSetupSnapshot): string { + switch (snapshot.phase) { + case "running": + return "Setting up worktree…"; + case "done": + return snapshot.stages.some((stage) => stage.status === "failed") + ? "Worktree ready, setup script failed" + : "Worktree ready"; + case "failed": + return "Worktree setup failed"; + case "cancelled": + return "Worktree setup cancelled"; + } +} + +/** + * Occupies the same slot, with the same metrics, as the "Working for" header + * so the handoff to the agent's turn only swaps the text. + */ +function SetupHeaderRow({ + snapshot, + totalElapsed, +}: { + snapshot: WorktreeSetupSnapshot; + totalElapsed: number | null; +}) { + const running = snapshot.phase === "running"; + const failed = snapshot.phase === "failed"; + const finishedWithFailedStage = + snapshot.phase === "done" && snapshot.stages.some((stage) => stage.status === "failed"); + const text = headerLabel(snapshot); + const tone = failed + ? "text-destructive-foreground" + : finishedWithFailedStage + ? "text-warning-foreground" + : "text-muted-foreground"; + return ( +
+
+ + {text} + {running ? {text} : null} + + {totalElapsed !== null ? ( + + {formatDuration(totalElapsed)} + + ) : null} +
+
+ ); +} + +/** One stage, rendered like a live work entry row. */ +function StageRow({ + stage, + nowMs, + scriptName, +}: { + stage: WorktreeSetupStage; + nowMs: number; + scriptName: string | null; +}) { + const elapsed = stageElapsedMs(stage, nowMs); + const label = + stage.id === "setup-script" && scriptName ? scriptName : worktreeSetupStageLabel(stage.id); + const running = stage.status === "running"; + const trailing = + stage.status === "pending" + ? null + : stage.status === "skipped" + ? (stage.detail ?? "skipped") + : stage.id === "checkout" && running && stage.percent !== null + ? `${stage.percent}%` + : stage.detail; + return ( +
+ + + + {label} + {trailing ? ( + + {trailing} + + ) : null} + {elapsed !== null && stage.status !== "skipped" && stage.status !== "pending" ? ( + + {formatDuration(elapsed)} + + ) : null} + {running ? ( + + + + + + {label} + + + ) : null} +
+ ); +} + +/** The server keeps this many trailing lines; the box is sized for exactly that. */ +const OUTPUT_TAIL_LINES = 4; +const OUTPUT_TAIL_SLOTS = Array.from({ length: OUTPUT_TAIL_LINES }, (_, slot) => slot); + +/** + * Fixed-height window onto the script's last lines. Rows never wrap and the + * box never grows or shrinks, so streaming output cannot push the timeline + * around while the script runs. + */ +function OutputTail({ lines, failed }: { lines: ReadonlyArray; failed: boolean }) { + const rows = OUTPUT_TAIL_SLOTS.map((slot) => ({ + slot, + line: lines[lines.length - OUTPUT_TAIL_LINES + slot] ?? "", + })); + return ( +
+      {rows.map(({ slot, line }) => (
+        
+ {line.length === 0 ? "\u00a0" : line} +
+ ))} +
+ ); +} + +function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) { + return ( +
+ {snapshot.branch ? ( + <> +
Branch
+
{snapshot.branch}
+ + ) : null} + {snapshot.baseRef ? ( + <> +
Base
+
{snapshot.baseRef}
+ + ) : null} + {snapshot.worktreePath ? ( + <> +
Path
+
{snapshot.worktreePath}
+ + ) : null} + {snapshot.setupScript ? ( + <> +
Setup
+
{snapshot.setupScript.command}
+ + ) : null} +
+ ); +} + +/** + * One-line summary of a settled setup under a live turn. A clean finish is + * removed from the timeline altogether, so this only renders the outcomes + * worth keeping: a failed script, a failed setup, or a cancelled one. + */ +function CollapsedSummaryRow({ + snapshot, + totalElapsed, +}: { + snapshot: WorktreeSetupSnapshot; + totalElapsed: number | null; +}) { + const status: WorktreeSetupStage["status"] = + snapshot.phase === "failed" || snapshot.phase === "cancelled" + ? "failed" + : snapshot.stages.some((stage) => stage.id === "setup-script" && stage.status === "failed") + ? "failed" + : "done"; + const label = headerLabel(snapshot); + return ( +
+ + + + {label} + {totalElapsed !== null ? ( + + {formatDuration(totalElapsed)} + + ) : null} +
+ ); +} + +export function WorktreeSetupCard({ + snapshot, + onCancel, + onWorkLocally, + onOpenTerminal, + embedded = false, +}: WorktreeSetupCardProps & { + /** + * The agent's turn is live and owns the "Working for" header. The stage + * list stays exactly where it was so the handoff never moves anything; a + * failed script that outlives the handoff collapses to a single row. + */ + embedded?: boolean; +}) { + const running = snapshot.phase === "running"; + const nowMs = useNowWhile(running); + const [detailsOpen, setDetailsOpen] = useState(false); + const totalElapsed = (() => { + const start = Date.parse(snapshot.startedAt); + const end = snapshot.endedAt ? Date.parse(snapshot.endedAt) : nowMs; + return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null; + })(); + const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script"); + const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending"; + const collapsed = embedded && !running; + // While running, the timeline's working row above the card carries the + // "Setting up worktree…" label (and keeps that slot when the agent takes + // over). The card only brings its own header for a settled outcome that + // has no working row to sit under. + const showHeader = !embedded && !running; + // The tail box is part of the script row's footprint while the script runs + // (and after it failed, so the last lines explain the failure). It mounts + // as soon as the script is running, empty lines and all, so the card takes + // its final height once instead of growing with each output line. + const showTail = + setupStage !== undefined && (setupStage.status === "running" || setupStage.status === "failed"); + + return ( +
+ {showHeader ? : null} + {collapsed ? ( + + ) : ( +
+ {snapshot.stages.map((stage) => ( +
+ + {stage.id === "setup-script" && showTail ? ( + + ) : null} +
+ ))} +
+ )} + + {snapshot.phase === "failed" && snapshot.error ? ( +

{snapshot.error}

+ ) : null} + + {detailsOpen ? : null} + + {/* Indented so the first label lines up with the stage labels: the icon + column, minus the xs button's own horizontal padding. */} +
+ + {showTerminal ? ( + + ) : null} + {onWorkLocally ? ( + + ) : null} + {onCancel && running ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 74b189a02f..774398feda 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -85,6 +85,8 @@ export interface NewProjectScriptInput { command: string; icon: ProjectScriptIcon; runOnWorktreeCreate: boolean; + /** Setup scripts only: hold the agent until the script exits. */ + waitForSetup: boolean; keybinding: string | null; /** Optional URL to open in the in-app preview when this script runs. */ previewUrl: string | null; @@ -99,6 +101,7 @@ export const EMPTY_PROJECT_SCRIPT_INPUT: NewProjectScriptInput = { command: "", icon: "play", runOnWorktreeCreate: false, + waitForSetup: false, keybinding: null, previewUrl: null, autoOpenPreview: false, @@ -123,6 +126,7 @@ export function editorRequestForScript( command: script.command, icon: script.icon, runOnWorktreeCreate: script.runOnWorktreeCreate, + waitForSetup: script.runOnWorktreeCreate && script.async === false, keybinding: keybindingValueForCommand(keybindings, commandForProjectScript(script.id)), previewUrl: script.previewUrl ?? null, autoOpenPreview: script.autoOpenPreview ?? false, @@ -158,6 +162,7 @@ export function ProjectScriptEditorDialog({ const [icon, setIcon] = useState("play"); const [iconPickerOpen, setIconPickerOpen] = useState(false); const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); + const [waitForSetup, setWaitForSetup] = useState(false); const [keybinding, setKeybinding] = useState(""); const [previewUrl, setPreviewUrl] = useState(""); const [autoOpenPreview, setAutoOpenPreview] = useState(false); @@ -188,6 +193,7 @@ export function ProjectScriptEditorDialog({ setIcon(request.initial.icon); setIconPickerOpen(false); setRunOnWorktreeCreate(request.initial.runOnWorktreeCreate); + setWaitForSetup(request.initial.waitForSetup); setKeybinding(request.initial.keybinding ?? ""); setPreviewUrl(request.initial.previewUrl ?? ""); setAutoOpenPreview(request.initial.autoOpenPreview); @@ -247,6 +253,7 @@ export function ProjectScriptEditorDialog({ command: trimmedCommand, icon, runOnWorktreeCreate, + waitForSetup: runOnWorktreeCreate && waitForSetup, keybinding: keybindingRule?.key ?? null, previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, @@ -396,6 +403,18 @@ export function ProjectScriptEditorDialog({ onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} /> +