diff --git a/apps/mobile/src/components/agents/preparation-group.tsx b/apps/mobile/src/components/agents/preparation-group.tsx new file mode 100644 index 0000000000..ca75f82ff2 --- /dev/null +++ b/apps/mobile/src/components/agents/preparation-group.tsx @@ -0,0 +1,151 @@ +import { useEffect, useState } from 'react'; +import { ActivityIndicator, Pressable, View } from 'react-native'; +import { AlertCircle, Check, ChevronRight, Terminal } from 'lucide-react-native'; +import { type PreparationAttempt, type PreparationStepSnapshot } from 'cloud-agent-sdk'; + +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +export function PreparationGroup({ attempt }: { attempt: PreparationAttempt }) { + const [expanded, setExpanded] = useState(attempt.status !== 'completed'); + const colors = useThemeColors(); + useEffect(() => { + setExpanded(attempt.status !== 'completed'); + }, [attempt.id, attempt.status]); + const title = attemptTitle(attempt.status); + return ( + + { + setExpanded(value => !value); + }} + accessibilityRole="button" + accessibilityLabel={title} + accessibilityState={{ expanded }} + className="flex-row items-center gap-2 px-3 py-3 active:bg-secondary" + > + + + {title} + + {expanded && ( + + {attempt.safeError && attempt.steps.length === 0 ? ( + + {attempt.safeError} + + ) : null} + {attempt.steps.map(step => ( + + ))} + + )} + + ); +} + +function AttemptIcon({ status }: { status: PreparationAttempt['status'] }) { + const colors = useThemeColors(); + if (status === 'running') { + return ; + } + if (status === 'completed') { + return ; + } + return ; +} + +function attemptTitle(status: PreparationAttempt['status']): string { + if (status === 'running') { + return 'Preparing environment'; + } + if (status === 'completed') { + return 'Preparation complete'; + } + return 'Preparation failed'; +} + +function PreparationStepRow({ step }: { step: PreparationStepSnapshot }) { + const [expanded, setExpanded] = useState(step.status !== 'completed'); + const colors = useThemeColors(); + useEffect(() => { + setExpanded(step.status !== 'completed'); + }, [step.id, step.status]); + const hasDetails = [ + step.command, + step.latestDetail, + step.outputTail, + step.safeError, + step.exitCode, + ].some(value => value !== undefined && value !== ''); + const label = + step.kind === 'setup_command' && step.commandIndex !== undefined + ? `Setup command ${step.commandIndex + 1}${step.commandCount ? ` of ${step.commandCount}` : ''}` + : step.label; + return ( + + { + setExpanded(value => !value); + }} + accessibilityRole={hasDetails ? 'button' : undefined} + accessibilityLabel={label} + accessibilityState={hasDetails ? { expanded } : undefined} + className="flex-row items-center gap-2 px-2 py-2.5 active:bg-secondary" + > + {hasDetails ? ( + + ) : ( + + )} + {step.kind === 'setup_command' ? ( + + ) : ( + + )} + + {label} + + + {expanded && hasDetails ? ( + + {step.command ? ( + + {step.command} + + ) : null} + {step.latestDetail ? ( + {step.latestDetail} + ) : null} + {step.safeError ? ( + + {step.safeError} + + ) : null} + {step.exitCode !== undefined ? ( + Exit code: {step.exitCode} + ) : null} + {step.outputTail ? ( + <> + {step.outputTruncated ? ( + Earlier output omitted + ) : null} + + {step.outputTail} + + + ) : null} + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 9b368ca6c7..f30efabf43 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -1,5 +1,10 @@ /* eslint-disable max-lines -- Session orchestration and its render paths are kept together. */ -import { type CloudStatus, type KiloSessionId, type StoredMessage } from 'cloud-agent-sdk'; +import { + type CloudStatus, + type KiloSessionId, + type PreparationAttempt, + type StoredMessage, +} from 'cloud-agent-sdk'; import { type Href, useRouter } from 'expo-router'; import { useAtomValue } from 'jotai'; import { MessageSquare } from 'lucide-react-native'; @@ -26,6 +31,7 @@ import { import { SessionContextSheet } from '@/components/agents/session-context-sheet'; import { useSessionManager } from '@/components/agents/session-provider'; import { SessionStatusIndicator } from '@/components/agents/session-status-indicator'; +import { PreparationGroup } from '@/components/agents/preparation-group'; import { shouldShowAgentWorkingIndicator, shouldShowFooterWorkingIndicator, @@ -72,6 +78,10 @@ type SessionDetailContentProps = { openedVia?: 'push' | 'app'; }; +type TranscriptItem = + | { type: 'message'; message: StoredMessage } + | { type: 'preparation'; attempt: PreparationAttempt }; + const COMPOSER_PLACEHOLDERS: Partial> = { preparing: 'Setting up environment...', finalizing: 'Wrapping up...', @@ -96,6 +106,7 @@ export function SessionDetailContent({ const isStreaming = useAtomValue(manager.atoms.isStreaming); const statusIndicator = useAtomValue(manager.atoms.statusIndicator); const cloudStatus = useAtomValue(manager.atoms.cloudStatus); + const preparationAttempts = useAtomValue(manager.atoms.preparationAttempts); const canSend = useAtomValue(manager.atoms.canSend); const isReadOnly = useAtomValue(manager.atoms.isReadOnly); const supportsAttachments = useAtomValue(manager.atoms.supportsAttachments); @@ -242,7 +253,10 @@ export function SessionDetailContent({ handleScrollEndDrag, handleMomentumScrollBegin, handleMomentumScrollEnd, - } = useSessionAutoScroll({ itemCount: messages.length, resetKey: sessionId }); + } = useSessionAutoScroll({ + itemCount: messages.length + preparationAttempts.length, + resetKey: sessionId, + }); const viewTrackedRef = useRef(null); useEffect(() => { @@ -297,13 +311,13 @@ export function SessionDetailContent({ sessionId, ]); - const lastAssistantIndex = useMemo(() => { + const lastAssistantMessageId = useMemo(() => { for (let i = messages.length - 1; i >= 0; i -= 1) { if (messages[i]?.info.role === 'assistant') { - return i; + return messages[i]?.info.id ?? null; } } - return -1; + return null; }, [messages]); const handleOpenChildSession = useCallback( @@ -314,19 +328,46 @@ export function SessionDetailContent({ [manager] ); + const transcript = useMemo(() => { + const byMessageId = new Map(); + for (const attempt of preparationAttempts) { + const attempts = byMessageId.get(attempt.triggerMessageId) ?? []; + byMessageId.set(attempt.triggerMessageId, [...attempts, attempt]); + } + const items: TranscriptItem[] = []; + for (const message of messages) { + items.push({ type: 'message', message }); + for (const attempt of byMessageId.get(message.info.id) ?? []) { + items.push({ type: 'preparation', attempt }); + } + } + for (const attempt of preparationAttempts) { + if ( + !byMessageId.has(attempt.triggerMessageId) || + !messages.some(message => message.info.id === attempt.triggerMessageId) + ) { + items.push({ type: 'preparation', attempt }); + } + } + return items; + }, [messages, preparationAttempts]); + const renderItem = useCallback( - ({ item, index }: { item: StoredMessage; index: number }) => ( - - ), + ({ item }: { item: TranscriptItem }) => + item.type === 'preparation' ? ( + + ) : ( + + ), [ - lastAssistantIndex, + lastAssistantMessageId, isStreaming, getChildMessages, reasoningDefaultExpanded, @@ -606,8 +647,10 @@ export function SessionDetailContent({ return ( item.info.id} + data={transcript} + keyExtractor={item => + item.type === 'message' ? item.message.info.id : `preparation:${item.attempt.id}` + } renderItem={renderItem} onScroll={handleScroll} onScrollBeginDrag={handleScrollBeginDrag} diff --git a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx index 27416a0920..de9a934dc2 100644 --- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx @@ -1,6 +1,6 @@ 'use client'; -import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; import { useSearchParams } from 'next/navigation'; import { useMutation, useQueryClient } from '@tanstack/react-query'; @@ -19,6 +19,8 @@ import { MessageBubble } from './MessageBubble'; import { ChildSessionDrawer } from './ChildSessionDrawer'; import type { ChildSessionDrawerEntry, OpenChildSession } from './ChildSessionSection'; import { SessionStatusIndicator } from './SessionStatusIndicator'; +import { PreparationRow } from './PreparationRow'; +import { PreparationDrawer } from './PreparationDrawer'; import { WorkingIndicator } from './WorkingIndicator'; import { QuestionToolCard } from './QuestionToolCard'; import { QuestionContextProvider } from './QuestionContext'; @@ -51,7 +53,11 @@ import type { CloudAgentAttachments } from '@/lib/cloud-agent/constants'; import { SetPageTitle } from '@/components/SetPageTitle'; import { formatShortModelDisplayName } from '@/lib/format-model-name'; import type { AgentMode } from './types'; -import type { MessageDeliveryState, StoredMessage } from '@/lib/cloud-agent-sdk'; +import type { + MessageDeliveryState, + PreparationAttempt, + StoredMessage, +} from '@/lib/cloud-agent-sdk'; import type { WorkspaceTabId } from './terminal-tabs'; import type { TerminalStatus } from './useCloudAgentTerminal'; @@ -62,13 +68,17 @@ const StaticMessages = memo( ({ messages, pendingMessages, + preparationByMessageId, getChildMessages, onOpenChildSession, + onOpenPreparationDetails, }: { messages: StoredMessage[]; pendingMessages: ReadonlyMap; + preparationByMessageId: ReadonlyMap; getChildMessages?: (sessionId: string) => StoredMessage[]; onOpenChildSession?: OpenChildSession; + onOpenPreparationDetails: (attemptId: string) => void; }) => ( <> {messages.map(msg => ( @@ -79,6 +89,13 @@ const StaticMessages = memo( getChildMessages={getChildMessages} onOpenChildSession={onOpenChildSession} /> + {preparationByMessageId.get(msg.info.id)?.map(attempt => ( + + ))} ))} @@ -93,16 +110,20 @@ type DynamicMessagesProps = { active: boolean; messages: StoredMessage[]; pendingMessages: ReadonlyMap; + preparationByMessageId: ReadonlyMap; getChildMessages?: (sessionId: string) => StoredMessage[]; onOpenChildSession?: OpenChildSession; + onOpenPreparationDetails: (attemptId: string) => void; }; const DynamicMessages = memo( function DynamicMessages({ messages, pendingMessages, + preparationByMessageId, getChildMessages, onOpenChildSession, + onOpenPreparationDetails, }: DynamicMessagesProps) { return ( <> @@ -117,6 +138,13 @@ const DynamicMessages = memo( getChildMessages={getChildMessages} onOpenChildSession={onOpenChildSession} /> + {preparationByMessageId.get(msg.info.id)?.map(attempt => ( + + ))} ); })} @@ -130,8 +158,10 @@ const DynamicMessages = memo( previous.active === next.active && previous.messages === next.messages && previous.pendingMessages === next.pendingMessages && + previous.preparationByMessageId === next.preparationByMessageId && previous.getChildMessages === next.getChildMessages && - previous.onOpenChildSession === next.onOpenChildSession + previous.onOpenChildSession === next.onOpenChildSession && + previous.onOpenPreparationDetails === next.onOpenPreparationDetails ); } ); @@ -193,6 +223,8 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { const [childSessionDrawerContainer, setChildSessionDrawerContainer] = useState(null); const childSessionDrawerFocusTargetRef = useRef(null); + const [preparationDrawerAttemptId, setPreparationDrawerAttemptId] = useState(null); + const preparationDrawerFocusTargetRef = useRef(null); // URL-driven session switching const sessionIdFromParams = searchParams?.get('sessionId') ?? null; @@ -200,6 +232,8 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { if (sessionIdFromParams) { childSessionDrawerFocusTargetRef.current = null; setChildSessionStack([]); + preparationDrawerFocusTargetRef.current = null; + setPreparationDrawerAttemptId(null); void manager.switchSession(sessionIdFromParams as KiloSessionId); } }, [sessionIdFromParams, manager]); @@ -215,6 +249,7 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { const sessionId = useAtomValue(manager.atoms.sessionId); const activity = useAtomValue(manager.atoms.activity); const cloudStatus = useAtomValue(manager.atoms.cloudStatus); + const preparationAttempts = useAtomValue(manager.atoms.preparationAttempts); const activeQuestion = useAtomValue(manager.atoms.activeQuestion); const activePermission = useAtomValue(manager.atoms.activePermission); const activeSuggestion = useAtomValue(manager.atoms.activeSuggestion); @@ -554,6 +589,9 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { const activeElement = document.activeElement; childSessionDrawerFocusTargetRef.current = activeElement instanceof HTMLElement ? activeElement : null; + // The two drawers overlay the same chat pane — only one may be open. + preparationDrawerFocusTargetRef.current = null; + setPreparationDrawerAttemptId(null); setChildSessionStack([entry]); }, []); @@ -577,6 +615,28 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { focusTarget.focus(); }, []); + const handleOpenPreparationDetails = useCallback((attemptId: string) => { + const activeElement = document.activeElement; + preparationDrawerFocusTargetRef.current = + activeElement instanceof HTMLElement ? activeElement : null; + // The two drawers overlay the same chat pane — only one may be open. + childSessionDrawerFocusTargetRef.current = null; + setChildSessionStack([]); + setPreparationDrawerAttemptId(attemptId); + }, []); + + const handlePreparationDrawerOpenChange = useCallback((open: boolean) => { + if (!open) setPreparationDrawerAttemptId(null); + }, []); + + const handlePreparationDrawerCloseAutoFocus = useCallback((event: Event) => { + const focusTarget = preparationDrawerFocusTargetRef.current; + preparationDrawerFocusTargetRef.current = null; + if (!focusTarget?.isConnected) return; + event.preventDefault(); + focusTarget.focus(); + }, []); + // Expose the session's custom agents to the chat picker. Slug + name only; // the full config stays server-side. `GetSessionOutput.runtimeAgents` // already filters to enabled & non-hidden at send time, so we just pass @@ -695,6 +755,21 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { // free to pick their own model. const displayVariant = modelPickerLocked ? agentVariantOverride : sessionModels.selectedVariant; const displayAvailableVariants = modelPickerLocked ? [] : availableVariants; + const preparationByMessageId = useMemo(() => { + const byMessageId = new Map(); + for (const attempt of preparationAttempts) { + const attempts = byMessageId.get(attempt.triggerMessageId) ?? []; + byMessageId.set(attempt.triggerMessageId, [...attempts, attempt]); + } + return byMessageId; + }, [preparationAttempts]); + // A running preparation row already shows live progress inline, so the + // trailing progress row would repeat the same message beneath it. + const visibleStatusIndicator = + statusIndicator?.type === 'progress' && + preparationAttempts.some(attempt => attempt.status === 'running') + ? null + : statusIndicator; const placeholder = isLoading ? 'Loading session…' @@ -775,7 +850,7 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { className="relative flex min-h-0 flex-1 flex-col" >
0} + inert={childSessionStack.length > 0 || preparationDrawerAttemptId !== null} className="flex min-h-0 flex-1 flex-col" >
@@ -790,15 +865,19 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { {chatTabActive && ( @@ -807,8 +886,8 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { isStreaming={isStreaming} /> )} - {statusIndicator && ( - + {visibleStatusIndicator && ( + )}
@@ -956,6 +1035,12 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { onCloseAutoFocus={handleChildSessionDrawerCloseAutoFocus} portalContainer={childSessionDrawerContainer} /> +
diff --git a/apps/web/src/components/cloud-agent-next/PreparationDrawer.tsx b/apps/web/src/components/cloud-agent-next/PreparationDrawer.tsx new file mode 100644 index 0000000000..f6cedb9adb --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/PreparationDrawer.tsx @@ -0,0 +1,267 @@ +'use client'; + +import type { ComponentProps } from 'react'; +import { useAtomValue } from 'jotai'; +import { AlertCircle, Check, Terminal } from 'lucide-react'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import type { + PreparationAttempt, + PreparationStepSnapshot, + PreparationStepStatus, +} from '@/lib/cloud-agent-sdk'; +import { StatusSpinner } from '@/components/shared/StatusSpinner'; +import { cn } from '@/lib/utils'; +import { useManager } from './CloudAgentProvider'; +import { formatAttemptDuration, phaseStatus, phaseStatusLabel } from './preparation-phases'; +import { phaseDisplayText } from './preparation-summary'; +import { useStickToBottom } from './hooks/useStickToBottom'; + +type PreparationDrawerProps = { + attemptId: string | null; + onOpenChange: (open: boolean) => void; + onCloseAutoFocus?: ComponentProps['onCloseAutoFocus']; + portalContainer?: HTMLElement | null; +}; + +/** + * Slide-out details panel for a preparation attempt. The attempt is looked up + * from the manager atom by id on every render, so an open drawer keeps + * streaming step and output updates live. + */ +export function PreparationDrawer({ + attemptId, + onOpenChange, + onCloseAutoFocus, + portalContainer, +}: PreparationDrawerProps) { + const manager = useManager(); + const attempts = useAtomValue(manager.atoms.preparationAttempts); + const attempt = attemptId ? attempts.find(candidate => candidate.id === attemptId) : undefined; + + return ( + + event.preventDefault()} + > + + Environment preparation + + {attempt && attemptSummaryLine(attempt)} + + +
+ {attempt && } +
+
+
+ ); +} + +function attemptSummaryLine(attempt: PreparationAttempt): string { + const title = + attempt.status === 'running' + ? 'Preparing environment' + : attempt.status === 'completed' + ? 'Environment prepared' + : 'Preparation failed'; + const duration = formatAttemptDuration(attempt); + return duration ? `${title} · ${duration}` : title; +} + +function PreparationTimeline({ attempt }: { attempt: PreparationAttempt }) { + const phaseSteps = attempt.steps.filter(step => step.kind === 'phase'); + const commands = attempt.steps.filter(step => step.kind === 'setup_command'); + const commandsUnderPhase = phaseSteps.some(step => step.key === 'setup_commands'); + // A failed command card displays attempt.safeError as its fallback, and a + // failed phase row displays its own safeError inline — only surface the + // attempt error separately when no step already carries an error. + const errorShownOnStep = attempt.steps.some( + step => + step.status === 'failed' && (step.safeError !== undefined || step.kind === 'setup_command') + ); + + return ( + <> +
    + {phaseSteps.map(step => ( + + {step.key === 'setup_commands' && commands.length > 0 && ( + + )} + + ))} +
+ + {!commandsUnderPhase && commands.length > 0 && ( +
+

Setup commands

+ +
+ )} + + {attempt.safeError && !errorShownOnStep && ( +

{attempt.safeError}

+ )} + + ); +} + +function PhaseRow({ + step, + status, + children, +}: { + step: PreparationStepSnapshot; + status: PreparationStepStatus; + children?: React.ReactNode; +}) { + const duration = formatAttemptDuration(step); + + return ( +
  • +
    + + + {phaseDisplayText(step)} + + {phaseStatusLabel(status)} + {duration && ( + {duration} + )} +
    + {step.safeError &&

    {step.safeError}

    } + {children &&
    {children}
    } +
  • + ); +} + +function PhaseIcon({ status }: { status: PreparationStepStatus }) { + if (status === 'running') return ; + if (status === 'completed') return ; + return ; +} + +function CommandList({ + commands, + attemptError, +}: { + commands: readonly PreparationStepSnapshot[]; + attemptError?: string; +}) { + return ( +
      + {commands.map(command => ( +
    1. + +
    2. + ))} +
    + ); +} + +function CommandCard({ + step, + fallbackError, +}: { + step: PreparationStepSnapshot; + fallbackError?: string; +}) { + const error = step.safeError ?? fallbackError; + const output = useStickToBottom(step.outputTail); + + return ( +
    +
    + + + {step.command ?? step.label} + + + {commandStatus(step)} + +
    + {error && ( +

    + {error} +

    + )} + {step.outputTail && ( +
    +
    + Command output + {step.outputTruncated && Earlier output omitted} +
    +
    +            {step.outputTail}
    +          
    +
    + )} +
    + ); +} + +function commandStatus(step: PreparationStepSnapshot): string { + if (step.status === 'running') return 'Running'; + const status = step.status === 'failed' ? 'Failed' : 'Completed'; + return step.exitCode === undefined ? status : `${status}, exit ${step.exitCode}`; +} + +function CommandIcon({ status }: { status: PreparationStepSnapshot['status'] }) { + if (status === 'running') return ; + if (status === 'failed') { + return ; + } + return ; +} diff --git a/apps/web/src/components/cloud-agent-next/PreparationRow.tsx b/apps/web/src/components/cloud-agent-next/PreparationRow.tsx new file mode 100644 index 0000000000..a4fcad6294 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/PreparationRow.tsx @@ -0,0 +1,111 @@ +import { AlertCircle, Check } from 'lucide-react'; +import type { PreparationAttempt } from '@/lib/cloud-agent-sdk'; +import { StatusSpinner } from '@/components/shared/StatusSpinner'; +import { + extractTickerLines, + findRunningSetupCommand, + summarizePreparationAttempt, + type PreparationRowSummary, +} from './preparation-summary'; + +type PreparationRowProps = { + attempt: PreparationAttempt; + onOpenDetails: (attemptId: string) => void; +}; + +/** + * Minimal one-line preparation status, styled to read like the surrounding + * session status indicators. The row tracks the current step while the + * attempt runs and collapses to a summary once it finishes; clicking it opens + * the details drawer. While a setup command streams output, a short + * non-interactive tail of it ticks along underneath. + */ +export function PreparationRow({ attempt, onOpenDetails }: PreparationRowProps) { + const summary = summarizePreparationAttempt(attempt); + const tickerLines = extractTickerLines(findRunningSetupCommand(attempt)?.outputTail); + + return ( + + ); +} + +function RowLabel({ summary }: { summary: PreparationRowSummary }) { + if (summary.kind === 'starting') { + return Setting up environment…; + } + if (summary.kind === 'phase') { + return {summary.text}; + } + if (summary.kind === 'command') { + return ( + <> + Executing + {summary.command} + {summary.commandIndex !== undefined && summary.commandCount !== undefined && ( + + ({summary.commandIndex + 1} of {summary.commandCount}) + + )} + + ); + } + if (summary.kind === 'completed') { + return ( + <> + Environment prepared + {summary.duration && · {summary.duration}} + + ); + } + return ( + <> + Preparation failed + {summary.error && {summary.error}} + View details + + ); +} + +/** + * Fading tail of live command output. Deliberately inert: not scrollable, not + * announced (the drawer carries the accessible live log), newest line pinned + * to the bottom while the mask fades earlier lines out. + */ +function OutputTicker({ lines }: { lines: string[] }) { + return ( + + ); +} + +function AttemptIcon({ status }: { status: PreparationAttempt['status'] }) { + if (status === 'running') return ; + if (status === 'completed') return ; + return ; +} diff --git a/apps/web/src/components/cloud-agent-next/hooks/useStickToBottom.ts b/apps/web/src/components/cloud-agent-next/hooks/useStickToBottom.ts new file mode 100644 index 0000000000..e87608ee98 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/hooks/useStickToBottom.ts @@ -0,0 +1,28 @@ +import { useLayoutEffect, useRef, type RefObject } from 'react'; + +const STICK_THRESHOLD_PX = 24; + +/** + * Keeps a scrollable element pinned to its bottom as `content` grows, but + * only while the user is already at (or near) the bottom — scrolling up + * releases the pin, scrolling back near the bottom re-engages it. + */ +export function useStickToBottom( + content: unknown +): { ref: RefObject; onScroll: () => void } { + const ref = useRef(null); + const stickRef = useRef(true); + + const onScroll = () => { + const el = ref.current; + if (!el) return; + stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight <= STICK_THRESHOLD_PX; + }; + + useLayoutEffect(() => { + const el = ref.current; + if (el && stickRef.current) el.scrollTop = el.scrollHeight; + }, [content]); + + return { ref, onScroll }; +} diff --git a/apps/web/src/components/cloud-agent-next/preparation-phases.test.ts b/apps/web/src/components/cloud-agent-next/preparation-phases.test.ts new file mode 100644 index 0000000000..79a2fc58af --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/preparation-phases.test.ts @@ -0,0 +1,74 @@ +import type { PreparationStepSnapshot } from '@/lib/cloud-agent-sdk'; +import { + formatAttemptDuration, + humanizePhaseLabel, + phaseStatus, + phaseStatusLabel, +} from './preparation-phases'; + +function step(overrides: Partial): PreparationStepSnapshot { + return { + id: 'step-1', + key: 'workspace_setup', + kind: 'phase', + label: 'workspace setup', + status: 'running', + startedAt: 1000, + revision: 1, + ...overrides, + }; +} + +describe('humanizePhaseLabel', () => { + it('replaces underscores and capitalizes the first letter', () => { + expect(humanizePhaseLabel(step({ label: 'disk_check' }))).toBe('Disk check'); + expect(humanizePhaseLabel(step({ label: 'workspace setup' }))).toBe('Workspace setup'); + }); +}); + +describe('phaseStatus', () => { + it('reports the step status for regular phases', () => { + expect(phaseStatus(step({ status: 'completed' }), [])).toBe('completed'); + expect(phaseStatus(step({ status: 'failed' }), [])).toBe('failed'); + }); + + it('derives the setup_commands phase status from the command steps', () => { + const phase = step({ key: 'setup_commands', label: 'setup commands' }); + const completed = step({ kind: 'setup_command', status: 'completed' }); + const failed = step({ kind: 'setup_command', status: 'failed' }); + const running = step({ kind: 'setup_command', status: 'running' }); + expect(phaseStatus(phase, [completed])).toBe('completed'); + expect(phaseStatus(phase, [completed, running])).toBe('running'); + expect(phaseStatus(phase, [completed, failed])).toBe('failed'); + }); + + it('falls back to the step status for setup_commands without observed commands', () => { + expect(phaseStatus(step({ key: 'setup_commands', status: 'running' }), [])).toBe('running'); + }); +}); + +describe('formatAttemptDuration', () => { + it('returns undefined while the attempt is still running', () => { + expect(formatAttemptDuration({ startedAt: 1000 })).toBeUndefined(); + }); + + it('formats sub-minute durations as seconds', () => { + expect(formatAttemptDuration({ startedAt: 1000, completedAt: 13_400 })).toBe('12s'); + }); + + it('formats longer durations as minutes and seconds', () => { + expect(formatAttemptDuration({ startedAt: 0, completedAt: 125_000 })).toBe('2m 5s'); + }); + + it('clamps a clock skew to zero', () => { + expect(formatAttemptDuration({ startedAt: 5000, completedAt: 4000 })).toBe('0s'); + }); +}); + +describe('phaseStatusLabel', () => { + it('maps each status to a screen-reader label', () => { + expect(phaseStatusLabel('running')).toBe('Running'); + expect(phaseStatusLabel('completed')).toBe('Completed'); + expect(phaseStatusLabel('failed')).toBe('Failed'); + }); +}); diff --git a/apps/web/src/components/cloud-agent-next/preparation-phases.ts b/apps/web/src/components/cloud-agent-next/preparation-phases.ts new file mode 100644 index 0000000000..2f7f5ed1db --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/preparation-phases.ts @@ -0,0 +1,45 @@ +import type { + PreparationAttempt, + PreparationStepSnapshot, + PreparationStepStatus, +} from '@/lib/cloud-agent-sdk'; + +export function humanizePhaseLabel(step: Pick): string { + return step.label + .replaceAll('_', ' ') + .replace(/^./, firstCharacter => firstCharacter.toUpperCase()); +} + +/** + * A phase step's display status. The setup_commands phase owns the command + * steps, so its status aggregates theirs instead of trusting its own — a + * failed command must surface on the phase even while the phase step itself + * is still marked running. + */ +export function phaseStatus( + step: PreparationStepSnapshot, + commands: readonly PreparationStepSnapshot[] +): PreparationStepStatus { + if (step.key === 'setup_commands' && commands.length > 0) { + if (commands.some(command => command.status === 'failed')) return 'failed'; + if (commands.some(command => command.status === 'running')) return 'running'; + if (commands.every(command => command.status === 'completed')) return 'completed'; + } + return step.status; +} + +/** Compact "12s" / "2m 5s" duration for a finished attempt, or undefined. */ +export function formatAttemptDuration( + attempt: Pick +): string | undefined { + if (attempt.completedAt === undefined) return undefined; + const seconds = Math.max(0, Math.round((attempt.completedAt - attempt.startedAt) / 1000)); + if (seconds < 60) return `${seconds}s`; + return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; +} + +export function phaseStatusLabel(status: PreparationStepStatus): string { + if (status === 'running') return 'Running'; + if (status === 'completed') return 'Completed'; + return 'Failed'; +} diff --git a/apps/web/src/components/cloud-agent-next/preparation-summary.test.ts b/apps/web/src/components/cloud-agent-next/preparation-summary.test.ts new file mode 100644 index 0000000000..f8240321dd --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/preparation-summary.test.ts @@ -0,0 +1,166 @@ +import type { PreparationAttempt, PreparationStepSnapshot } from '@/lib/cloud-agent-sdk'; +import { + extractTickerLines, + findRunningSetupCommand, + summarizePreparationAttempt, +} from './preparation-summary'; + +function step(overrides: Partial): PreparationStepSnapshot { + return { + id: 'step-1', + key: 'workspace_setup', + kind: 'phase', + label: 'workspace setup', + status: 'running', + startedAt: 1000, + revision: 1, + ...overrides, + }; +} + +function attempt(overrides: Partial): PreparationAttempt { + return { + id: 'attempt-1', + triggerMessageId: 'message-1', + status: 'running', + startedAt: 1000, + revision: 1, + steps: [], + ...overrides, + }; +} + +describe('summarizePreparationAttempt', () => { + it('reports starting while no step is running yet', () => { + expect(summarizePreparationAttempt(attempt({}))).toEqual({ kind: 'starting' }); + const completedOnly = attempt({ steps: [step({ status: 'completed' })] }); + expect(summarizePreparationAttempt(completedOnly)).toEqual({ kind: 'starting' }); + }); + + it('shows the running phase progress message when one was reported', () => { + const running = attempt({ + steps: [step({ key: 'disk_check', label: 'disk_check', latestDetail: 'Checking disk…' })], + }); + expect(summarizePreparationAttempt(running)).toEqual({ + kind: 'phase', + text: 'Checking disk…', + }); + }); + + it('falls back to the humanized phase label without a progress message', () => { + const running = attempt({ steps: [step({ key: 'disk_check', label: 'disk_check' })] }); + expect(summarizePreparationAttempt(running)).toEqual({ kind: 'phase', text: 'Disk check' }); + }); + + it('prefers a running setup command over a running phase', () => { + const running = attempt({ + steps: [ + step({ id: 'phase', key: 'setup_commands', label: 'setup commands' }), + step({ + id: 'command', + kind: 'setup_command', + key: 'setup_command:1', + command: 'npm install', + commandIndex: 1, + commandCount: 3, + }), + ], + }); + expect(summarizePreparationAttempt(running)).toEqual({ + kind: 'command', + command: 'npm install', + commandIndex: 1, + commandCount: 3, + }); + }); + + it('falls back to the step label when a command has no command string', () => { + const running = attempt({ + steps: [step({ kind: 'setup_command', label: 'setup command 1' })], + }); + expect(summarizePreparationAttempt(running)).toEqual({ + kind: 'command', + command: 'setup command 1', + commandIndex: undefined, + commandCount: undefined, + }); + }); + + it('reports the duration for a completed attempt', () => { + const completed = attempt({ status: 'completed', completedAt: 13_400 }); + expect(summarizePreparationAttempt(completed)).toEqual({ kind: 'completed', duration: '12s' }); + }); + + it('reports no duration for a completed attempt without completedAt', () => { + const completed = attempt({ status: 'completed' }); + expect(summarizePreparationAttempt(completed)).toEqual({ + kind: 'completed', + duration: undefined, + }); + }); + + it('surfaces the attempt error for a failed attempt', () => { + const failed = attempt({ status: 'failed', safeError: 'clone failed' }); + expect(summarizePreparationAttempt(failed)).toEqual({ kind: 'failed', error: 'clone failed' }); + }); + + it('falls back to the last failed step error, then to none', () => { + const failed = attempt({ + status: 'failed', + steps: [ + step({ id: 'a', status: 'failed', safeError: 'first error' }), + step({ id: 'b', status: 'failed', safeError: 'second error' }), + ], + }); + expect(summarizePreparationAttempt(failed)).toEqual({ kind: 'failed', error: 'second error' }); + expect(summarizePreparationAttempt(attempt({ status: 'failed' }))).toEqual({ + kind: 'failed', + error: undefined, + }); + }); +}); + +describe('findRunningSetupCommand', () => { + it('returns the running command, ignoring phases and finished commands', () => { + const running = step({ id: 'running', kind: 'setup_command' }); + const candidate = attempt({ + steps: [ + step({ id: 'phase' }), + step({ id: 'done', kind: 'setup_command', status: 'completed' }), + running, + ], + }); + expect(findRunningSetupCommand(candidate)).toBe(running); + }); + + it('returns undefined when no command is running', () => { + const candidate = attempt({ + steps: [step({}), step({ id: 'done', kind: 'setup_command', status: 'completed' })], + }); + expect(findRunningSetupCommand(candidate)).toBeUndefined(); + }); +}); + +describe('extractTickerLines', () => { + it('returns no lines for missing or empty output', () => { + expect(extractTickerLines(undefined)).toEqual([]); + expect(extractTickerLines('')).toEqual([]); + }); + + it('returns all lines when there are fewer than the maximum', () => { + expect(extractTickerLines('one\ntwo')).toEqual(['one', 'two']); + }); + + it('returns only the trailing lines when there are more', () => { + expect(extractTickerLines('1\n2\n3\n4\n5')).toEqual(['3', '4', '5']); + expect(extractTickerLines('1\n2\n3', 2)).toEqual(['2', '3']); + }); + + it('ignores a trailing newline and normalizes CRLF', () => { + expect(extractTickerLines('one\r\ntwo\n')).toEqual(['one', 'two']); + }); + + it('preserves blank interior lines', () => { + expect(extractTickerLines('one\n\ntwo')).toEqual(['one', '', 'two']); + }); +}); diff --git a/apps/web/src/components/cloud-agent-next/preparation-summary.ts b/apps/web/src/components/cloud-agent-next/preparation-summary.ts new file mode 100644 index 0000000000..087f88dada --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/preparation-summary.ts @@ -0,0 +1,66 @@ +import type { PreparationAttempt, PreparationStepSnapshot } from '@/lib/cloud-agent-sdk'; +import { formatAttemptDuration, humanizePhaseLabel } from './preparation-phases'; + +/** The single line the chat row shows for a preparation attempt. */ +export type PreparationRowSummary = + | { kind: 'starting' } + | { kind: 'phase'; text: string } + | { kind: 'command'; command: string; commandIndex?: number; commandCount?: number } + | { kind: 'completed'; duration?: string } + | { kind: 'failed'; error?: string }; + +export function summarizePreparationAttempt(attempt: PreparationAttempt): PreparationRowSummary { + if (attempt.status === 'completed') { + return { kind: 'completed', duration: formatAttemptDuration(attempt) }; + } + if (attempt.status === 'failed') { + return { kind: 'failed', error: attempt.safeError ?? lastFailedStepError(attempt.steps) }; + } + + const command = findRunningSetupCommand(attempt); + if (command) { + return { + kind: 'command', + command: command.command ?? command.label, + commandIndex: command.commandIndex, + commandCount: command.commandCount, + }; + } + + const phase = attempt.steps.find(step => step.kind === 'phase' && step.status === 'running'); + if (phase) { + return { kind: 'phase', text: phaseDisplayText(phase) }; + } + + return { kind: 'starting' }; +} + +/** + * The one line shown for a phase step. The progress message ("Cloning + * repository…") and the phase label ("Cloning") say the same thing — show + * only the message, which is friendlier and can carry live progress, and + * fall back to the humanized label for steps that never reported one. + */ +export function phaseDisplayText(step: PreparationStepSnapshot): string { + return step.latestDetail ?? humanizePhaseLabel(step); +} + +/** The setup command currently streaming output, if any. */ +export function findRunningSetupCommand( + attempt: PreparationAttempt +): PreparationStepSnapshot | undefined { + return attempt.steps.find(step => step.kind === 'setup_command' && step.status === 'running'); +} + +/** Last `maxLines` lines of an output tail, for the live ticker. */ +export function extractTickerLines(outputTail: string | undefined, maxLines = 3): string[] { + if (!outputTail) return []; + const lines = outputTail.replaceAll('\r\n', '\n').split('\n'); + if (lines.at(-1) === '') lines.pop(); + return lines.slice(-maxLines); +} + +function lastFailedStepError(steps: readonly PreparationStepSnapshot[]): string | undefined { + return steps.findLast(step => step.status === 'failed' && step.safeError !== undefined) + ?.safeError; +} diff --git a/apps/web/src/lib/cloud-agent-sdk/index.ts b/apps/web/src/lib/cloud-agent-sdk/index.ts index 0e083919b4..7879d92c6a 100644 --- a/apps/web/src/lib/cloud-agent-sdk/index.ts +++ b/apps/web/src/lib/cloud-agent-sdk/index.ts @@ -139,6 +139,11 @@ export type { SuggestionAction, SuggestionState, MessageDeliveryState, + PreparationAttempt, + PreparationAttemptStatus, + PreparationStepKind, + PreparationStepSnapshot, + PreparationStepStatus, ServiceStateSnapshot, SessionInfo, KiloSessionId, diff --git a/apps/web/src/lib/cloud-agent-sdk/normalizer.test.ts b/apps/web/src/lib/cloud-agent-sdk/normalizer.test.ts index 3852376276..e009f6099c 100644 --- a/apps/web/src/lib/cloud-agent-sdk/normalizer.test.ts +++ b/apps/web/src/lib/cloud-agent-sdk/normalizer.test.ts @@ -1005,6 +1005,27 @@ describe('normalize', () => { }); }); + it('carries v2 attempt_started events through', () => { + const result = normalize( + createRaw('preparing', { + step: 'workspace_setup', + message: 'Preparing environment', + version: 2, + attemptId: 'attempt-1', + triggerMessageId: 'msg-1', + revision: 1, + timestamp: 1000, + action: 'attempt_started', + }) + ); + expect(result).toMatchObject({ + type: 'preparing', + version: 2, + attemptId: 'attempt-1', + action: 'attempt_started', + }); + }); + it('returns null when step is missing', () => { expect(normalize(createRaw('preparing', { message: 'Cloning repository...' }))).toBeNull(); }); diff --git a/apps/web/src/lib/cloud-agent-sdk/normalizer.ts b/apps/web/src/lib/cloud-agent-sdk/normalizer.ts index c0c510fced..31ccd857ff 100644 --- a/apps/web/src/lib/cloud-agent-sdk/normalizer.ts +++ b/apps/web/src/lib/cloud-agent-sdk/normalizer.ts @@ -5,7 +5,13 @@ */ import { z } from 'zod'; import type { Part, SessionStatus, QuestionInfo, Message } from '@/types/opencode.gen'; -import type { SessionInfo, CloudStatus, SuggestionAction, SlashCommandInfo } from './types'; +import type { + SessionInfo, + CloudStatus, + SuggestionAction, + SlashCommandInfo, + PreparationStepSnapshot, +} from './types'; import { cloudAgentEventSchema, kilocodePayloadSchema, @@ -101,7 +107,38 @@ export type ServiceEvent = branch?: string; } | { type: 'warning' } - | { type: 'preparing'; step: string; message: string; branch?: string } + | { + type: 'preparing'; + step: string; + message: string; + branch?: string; + version?: 2; + attemptId?: string; + triggerMessageId?: string; + revision?: number; + timestamp?: number; + action?: string; + stepId?: string; + kind?: 'phase' | 'setup_command'; + label?: string; + command?: string; + commandIndex?: number; + commandCount?: number; + detail?: string; + output?: string; + safeError?: string; + exitCode?: number; + attempt?: { + id: string; + triggerMessageId: string; + status: 'running' | 'completed' | 'failed'; + startedAt: number; + completedAt?: number; + safeError?: string; + revision: number; + }; + stepSnapshot?: PreparationStepSnapshot; + } | { type: 'autocommit_started'; messageId: string; message?: string } | { type: 'autocommit_completed'; @@ -379,6 +416,33 @@ function normalizeInnerEvent(eventType: string, data: unknown): NormalizedEvent step: r.data.step, message: r.data.message, branch: r.data.branch, + ...(r.data.version === 2 && + r.data.attemptId && + r.data.triggerMessageId && + r.data.revision !== undefined && + r.data.timestamp !== undefined && + r.data.action + ? { + version: 2 as const, + attemptId: r.data.attemptId, + triggerMessageId: r.data.triggerMessageId, + revision: r.data.revision, + timestamp: r.data.timestamp, + action: r.data.action, + ...(r.data.stepId === undefined ? {} : { stepId: r.data.stepId }), + ...(r.data.kind === undefined ? {} : { kind: r.data.kind }), + ...(r.data.label === undefined ? {} : { label: r.data.label }), + ...(r.data.command === undefined ? {} : { command: r.data.command }), + ...(r.data.commandIndex === undefined ? {} : { commandIndex: r.data.commandIndex }), + ...(r.data.commandCount === undefined ? {} : { commandCount: r.data.commandCount }), + ...(r.data.detail === undefined ? {} : { detail: r.data.detail }), + ...(r.data.output === undefined ? {} : { output: r.data.output }), + ...(r.data.safeError === undefined ? {} : { safeError: r.data.safeError }), + ...(r.data.exitCode === undefined ? {} : { exitCode: r.data.exitCode }), + ...(r.data.attempt === undefined ? {} : { attempt: r.data.attempt }), + ...(r.data.stepSnapshot === undefined ? {} : { stepSnapshot: r.data.stepSnapshot }), + } + : {}), }; } diff --git a/apps/web/src/lib/cloud-agent-sdk/schemas.ts b/apps/web/src/lib/cloud-agent-sdk/schemas.ts index db4fbf2630..f2b6d12bfa 100644 --- a/apps/web/src/lib/cloud-agent-sdk/schemas.ts +++ b/apps/web/src/lib/cloud-agent-sdk/schemas.ts @@ -634,11 +634,73 @@ export type ErrorData = z.infer; export const wrapperDisconnectedDataSchema = z.unknown(); export type WrapperDisconnectedData = z.infer; -export const preparingDataSchema = z.object({ - step: z.string(), - message: z.string(), - branch: z.string().optional(), +const preparationStepSchema = z.object({ + id: z.string(), + key: z.string(), + kind: z.enum(['phase', 'setup_command']), + label: z.string(), + status: z.enum(['running', 'completed', 'failed']), + startedAt: z.number(), + completedAt: z.number().optional(), + revision: z.number(), + latestDetail: z.string().optional(), + safeError: z.string().optional(), + command: z.string().optional(), + commandIndex: z.number().optional(), + commandCount: z.number().optional(), + outputTail: z.string().optional(), + outputTruncated: z.boolean().optional(), + exitCode: z.number().optional(), +}); + +const preparationAttemptSchema = z.object({ + id: z.string(), + triggerMessageId: z.string(), + status: z.enum(['running', 'completed', 'failed']), + startedAt: z.number(), + completedAt: z.number().optional(), + safeError: z.string().optional(), + revision: z.number(), }); + +export const preparingDataSchema = z + .object({ + step: z.string(), + message: z.string(), + branch: z.string().optional(), + version: z.literal(2).optional(), + attemptId: z.string().optional(), + triggerMessageId: z.string().optional(), + revision: z.number().optional(), + timestamp: z.number().optional(), + action: z + .enum([ + 'attempt_started', + 'step_started', + 'step_progress', + 'step_output', + 'step_completed', + 'step_failed', + 'attempt_completed', + 'attempt_failed', + 'attempt_snapshot', + 'step_snapshot', + ]) + .optional(), + stepId: z.string().optional(), + kind: z.enum(['phase', 'setup_command']).optional(), + label: z.string().optional(), + command: z.string().optional(), + commandIndex: z.number().optional(), + commandCount: z.number().optional(), + detail: z.string().optional(), + output: z.string().optional(), + safeError: z.string().optional(), + exitCode: z.number().optional(), + attempt: preparationAttemptSchema.optional(), + stepSnapshot: preparationStepSchema.optional(), + }) + .passthrough(); export type PreparingData = z.infer; export const autocommitStartedDataSchema = z.object({ diff --git a/apps/web/src/lib/cloud-agent-sdk/service-state.test.ts b/apps/web/src/lib/cloud-agent-sdk/service-state.test.ts index 0aaf4d4da3..78aea55ed5 100644 --- a/apps/web/src/lib/cloud-agent-sdk/service-state.test.ts +++ b/apps/web/src/lib/cloud-agent-sdk/service-state.test.ts @@ -38,6 +38,7 @@ describe('createServiceState', () => { expect(snap.activity).toEqual({ type: 'connecting' }); expect(snap.status).toEqual({ type: 'idle' }); expect(snap.cloudStatus).toBeNull(); + expect(snap.setupLog).toEqual([]); expect(snap.sessionInfo).toBeNull(); expect(snap.question).toBeNull(); expect(snap.permission).toBeNull(); @@ -484,6 +485,341 @@ describe('createServiceState', () => { expect(onError).toHaveBeenCalledWith('Clone failed'); expect(onPreparationFailed).toHaveBeenCalledWith('Clone failed'); }); + + it('accumulates setup_commands messages into setupLog', () => { + const state = createServiceState(makeConfig()); + + state.process({ + type: 'preparing', + step: 'setup_commands', + message: 'Running setup command 1 of 2: npm install', + }); + state.process({ type: 'preparing', step: 'setup_commands', message: 'added 42 packages' }); + state.process({ + type: 'preparing', + step: 'setup_commands', + message: 'Running setup command 2 of 2: pip install', + }); + + expect(state.getSetupLog()).toEqual([ + 'Running setup command 1 of 2: npm install', + 'added 42 packages', + 'Running setup command 2 of 2: pip install', + ]); + }); + + it('clears setupLog on ready', () => { + const state = createServiceState(makeConfig()); + + state.process({ type: 'preparing', step: 'setup_commands', message: 'npm install' }); + state.process({ type: 'preparing', step: 'ready', message: 'Ready' }); + + expect(state.getSetupLog()).toEqual([]); + }); + + it('clears setupLog on failed', () => { + const state = createServiceState(makeConfig()); + + state.process({ type: 'preparing', step: 'setup_commands', message: 'npm install' }); + state.process({ type: 'preparing', step: 'failed', message: 'Setup failed' }); + + expect(state.getSetupLog()).toEqual([]); + }); + + it('does not accumulate non-setup_commands steps', () => { + const state = createServiceState(makeConfig()); + + state.process({ type: 'preparing', step: 'cloning', message: 'Cloning...' }); + state.process({ type: 'preparing', step: 'branch', message: 'Creating branch...' }); + + expect(state.getSetupLog()).toEqual([]); + }); + + it('v2 attempt lifecycle drives cloudStatus and stale events cannot regress it', () => { + const state = createServiceState(makeConfig()); + const base = { + type: 'preparing' as const, + version: 2 as const, + attemptId: 'attempt-1', + triggerMessageId: 'message-1', + }; + + state.process({ + ...base, + revision: 1, + timestamp: 1_000, + step: 'workspace_setup', + message: 'Preparing environment', + action: 'attempt_started', + }); + expect(state.getCloudStatus()?.type).toBe('preparing'); + + state.process({ + ...base, + revision: 5, + timestamp: 2_000, + step: 'ready', + message: 'Preparation complete', + action: 'attempt_completed', + }); + expect(state.getCloudStatus()).toEqual({ type: 'ready' }); + + // A late-arriving wrapper event with an old revision must not flip the + // session back to 'preparing' — that would disable the chat input. + state.process({ + ...base, + revision: 3, + timestamp: 1_500, + step: 'kilo_server', + message: 'Starting Kilo', + action: 'step_started', + stepId: 'phase:kilo_server', + kind: 'phase', + label: 'kilo server', + }); + expect(state.getCloudStatus()).toEqual({ type: 'ready' }); + }); + + it('v2 attempt_failed sets cloudStatus to error with the safe error', () => { + const state = createServiceState(makeConfig()); + state.process({ + type: 'preparing', + version: 2, + attemptId: 'attempt-1', + triggerMessageId: 'message-1', + revision: 1, + timestamp: 1_000, + step: 'workspace_setup', + message: 'Preparing environment', + action: 'attempt_started', + }); + + state.process({ + type: 'preparing', + version: 2, + attemptId: 'attempt-1', + triggerMessageId: 'message-1', + revision: 2, + timestamp: 2_000, + step: 'failed', + message: 'failed', + action: 'attempt_failed', + safeError: 'Clone failed', + }); + + expect(state.getCloudStatus()).toEqual({ type: 'error', message: 'Clone failed' }); + }); + + it('replayed snapshots of a completed attempt leave cloudStatus ready', () => { + const state = createServiceState(makeConfig()); + + state.process({ + type: 'preparing', + version: 2, + attemptId: 'attempt-1', + triggerMessageId: 'message-1', + revision: 4, + timestamp: 1_000, + step: 'workspace_setup', + message: 'Preparation snapshot', + action: 'attempt_snapshot', + attempt: { + id: 'attempt-1', + triggerMessageId: 'message-1', + status: 'completed', + startedAt: 1_000, + completedAt: 2_000, + revision: 4, + }, + }); + state.process({ + type: 'preparing', + version: 2, + attemptId: 'attempt-1', + triggerMessageId: 'message-1', + revision: 3, + timestamp: 1_500, + step: 'kilo_server', + message: 'Preparation snapshot', + action: 'step_snapshot', + stepId: 'phase:kilo_server', + stepSnapshot: { + id: 'phase:kilo_server', + key: 'kilo_server', + kind: 'phase', + label: 'kilo server', + status: 'completed', + startedAt: 1_100, + completedAt: 1_500, + revision: 3, + }, + }); + + expect(state.getCloudStatus()).toEqual({ type: 'ready' }); + }); + + it('wrapper attempt_started keeps the original start of a running attempt', () => { + const state = createServiceState(makeConfig()); + const base = { + type: 'preparing' as const, + version: 2 as const, + attemptId: 'attempt-1', + triggerMessageId: 'message-1', + step: 'workspace_setup', + message: 'Preparing environment', + }; + + state.process({ + ...base, + revision: 1, + timestamp: 1_000, + action: 'attempt_started', + }); + state.process({ + ...base, + revision: 1_750_000_000_000, + timestamp: 9_000, + action: 'attempt_started', + }); + + expect(state.getPreparationAttempts()[0]?.startedAt).toBe(1_000); + }); + + it('wrapper attempt_started settles the running steps of the previous emitter', () => { + const state = createServiceState(makeConfig()); + const base = { + type: 'preparing' as const, + version: 2 as const, + attemptId: 'attempt-1', + triggerMessageId: 'message-1', + step: 'sandbox_boot', + message: 'Starting sandbox agent...', + }; + + state.process({ ...base, revision: 1, timestamp: 1_000, action: 'attempt_started' }); + state.process({ + ...base, + revision: 2, + timestamp: 2_000, + action: 'step_started', + stepId: 'phase:sandbox_boot', + kind: 'phase', + label: 'sandbox boot', + }); + state.process({ ...base, revision: 3, timestamp: 5_000, action: 'attempt_started' }); + + const step = state.getPreparationAttempts()[0]?.steps[0]; + expect(step?.status).toBe('completed'); + expect(step?.completedAt).toBe(5_000); + }); + + it('a terminal attempt settles its dangling running steps', () => { + const state = createServiceState(makeConfig()); + const base = { + type: 'preparing' as const, + version: 2 as const, + attemptId: 'attempt-1', + triggerMessageId: 'message-1', + step: 'kilo_server', + message: 'Starting Kilo...', + }; + + state.process({ ...base, revision: 1, timestamp: 1_000, action: 'attempt_started' }); + state.process({ + ...base, + revision: 2, + timestamp: 2_000, + action: 'step_started', + stepId: 'phase:kilo_server', + kind: 'phase', + label: 'kilo server', + }); + state.process({ + ...base, + revision: 3, + timestamp: 6_000, + step: 'failed', + message: 'Clone failed', + action: 'attempt_failed', + safeError: 'Clone failed', + }); + + const step = state.getPreparationAttempts()[0]?.steps[0]; + expect(step?.status).toBe('failed'); + expect(step?.safeError).toBe('Clone failed'); + expect(step?.completedAt).toBe(6_000); + }); + + it('hydrates an attempt and its steps from reload snapshots', () => { + const state = createServiceState(makeConfig()); + + state.process({ + type: 'preparing', + version: 2, + attemptId: 'attempt-1', + triggerMessageId: 'message-1', + revision: 4, + timestamp: 1_000, + step: 'workspace_setup', + message: 'Preparation snapshot', + action: 'attempt_snapshot', + attempt: { + id: 'attempt-1', + triggerMessageId: 'message-1', + status: 'completed', + startedAt: 1_000, + completedAt: 2_000, + revision: 4, + }, + }); + state.process({ + type: 'preparing', + version: 2, + attemptId: 'attempt-1', + triggerMessageId: 'message-1', + revision: 3, + timestamp: 1_500, + step: 'setup_commands', + message: 'Preparation snapshot', + action: 'step_snapshot', + stepId: 'step-1', + stepSnapshot: { + id: 'step-1', + key: 'setup_commands', + kind: 'setup_command', + label: 'Install dependencies', + status: 'completed', + startedAt: 1_100, + completedAt: 1_500, + revision: 3, + outputTail: 'Installed dependencies', + }, + }); + + expect(state.getPreparationAttempts()).toEqual([ + { + id: 'attempt-1', + triggerMessageId: 'message-1', + status: 'completed', + startedAt: 1_000, + completedAt: 2_000, + revision: 4, + steps: [ + { + id: 'step-1', + key: 'setup_commands', + kind: 'setup_command', + label: 'Install dependencies', + status: 'completed', + startedAt: 1_100, + completedAt: 1_500, + revision: 3, + outputTail: 'Installed dependencies', + }, + ], + }, + ]); + }); }); describe('cloud.status', () => { diff --git a/apps/web/src/lib/cloud-agent-sdk/service-state.ts b/apps/web/src/lib/cloud-agent-sdk/service-state.ts index ad2a22e642..adc37c197f 100644 --- a/apps/web/src/lib/cloud-agent-sdk/service-state.ts +++ b/apps/web/src/lib/cloud-agent-sdk/service-state.ts @@ -18,6 +18,8 @@ import type { SuggestionState, CloudStatus, MessageDeliveryState, + PreparationAttempt, + PreparationStepSnapshot, } from './types'; type ServiceStateConfig = { @@ -66,6 +68,9 @@ type ServiceState = { getActivity(): SessionActivity; getStatus(): AgentStatus; getCloudStatus(): CloudStatus | null; + /** @deprecated Legacy transient setup output. */ + getSetupLog(): readonly string[]; + getPreparationAttempts(): readonly PreparationAttempt[]; getQuestion(): QuestionState | null; getPermission(): PermissionState | null; getSuggestion(): SuggestionState | null; @@ -89,6 +94,8 @@ function createServiceState(config: ServiceStateConfig): ServiceState { let activity: SessionActivity = INITIAL_ACTIVITY; let status: AgentStatus = IDLE_STATUS; let cloudStatus: CloudStatus | null = null; + let setupLog: string[] = []; + let preparationAttempts: PreparationAttempt[] = []; let sessionInfo: SessionInfo | null = null; let question: QuestionState | null = null; let permission: PermissionState | null = null; @@ -149,6 +156,7 @@ function createServiceState(config: ServiceStateConfig): ServiceState { function processStopped(event: Extract): void { activity = { type: 'idle' }; cloudStatus = null; + setupLog = []; switch (event.reason) { case 'complete': @@ -272,20 +280,208 @@ function createServiceState(config: ServiceStateConfig): ServiceState { } function processPreparing(event: Extract): void { + if (event.version === 2 && event.attemptId && event.triggerMessageId && event.action) { + const attempt = processPreparationEvent(event); + // Only an event that actually advanced the attempt may move cloudStatus. + // Stale duplicates and replayed snapshots of a finished attempt would + // otherwise flip a ready session back to 'preparing' and permanently + // disable the chat input. + if (attempt) { + cloudStatus = + attempt.status === 'completed' + ? { type: 'ready' } + : attempt.status === 'failed' + ? { type: 'error', message: attempt.safeError ?? event.message } + : { type: 'preparing', step: event.step, message: event.message }; + } + notify(); + return; + } if (event.step === 'ready') { cloudStatus = { type: 'ready' }; + setupLog = []; if (event.branch) config.onBranchChanged?.(event.branch); config.onPreparationReady?.(); } else if (event.step === 'failed') { cloudStatus = { type: 'error', message: event.message }; + setupLog = []; config.onError?.(event.message); config.onPreparationFailed?.(event.message); } else { cloudStatus = { type: 'preparing', step: event.step, message: event.message }; + if (event.step === 'setup_commands' && event.message) { + setupLog = [...setupLog, event.message]; + } } notify(); } + /** + * Apply one v2 preparation event to the attempts list. Returns the attempt + * in its post-event state when the event advanced it, or null when the + * event was stale or unusable and nothing changed. + */ + function processPreparationEvent( + event: Extract + ): PreparationAttempt | null { + if ( + event.version !== 2 || + !event.attemptId || + !event.triggerMessageId || + event.revision === undefined || + event.timestamp === undefined || + !event.action + ) { + return null; + } + const eventTimestamp = event.timestamp; + const eventRevision = event.revision; + const existing = preparationAttempts.find(attempt => attempt.id === event.attemptId); + // Steps come from two independent emitters (the server before the wrapper + // boots, the wrapper after), and each only completes its own previous + // step. When the attempt changes hands (a running attempt re-announced) + // or reaches a terminal state, settle any step still marked running — + // its emitter is gone and no completion will ever arrive. + const settleRunningSteps = ( + steps: readonly PreparationStepSnapshot[], + status: 'completed' | 'failed', + safeError?: string + ): PreparationStepSnapshot[] => + steps.map(step => + step.status === 'running' + ? { + ...step, + status, + completedAt: eventTimestamp, + ...(status === 'failed' && safeError !== undefined ? { safeError } : {}), + revision: eventRevision, + } + : step + ); + if (event.action === 'attempt_started') { + if (existing && existing.revision >= event.revision) return null; + const handedOff = existing?.status === 'running' ? existing : undefined; + const attempt: PreparationAttempt = { + id: event.attemptId, + triggerMessageId: event.triggerMessageId, + status: 'running', + // The wrapper re-announces the attempt the server already started; + // keep the original start so the duration spans the whole preparation. + startedAt: handedOff ? handedOff.startedAt : event.timestamp, + revision: event.revision, + steps: handedOff + ? settleRunningSteps(handedOff.steps, 'completed') + : (existing?.steps ?? []), + }; + preparationAttempts = [ + ...preparationAttempts.filter(item => item.id !== attempt.id), + attempt, + ].sort((a, b) => a.startedAt - b.startedAt); + return attempt; + } + if (event.action === 'attempt_snapshot' && event.attempt) { + if (existing && existing.revision > event.attempt.revision) return null; + const snapshot = event.attempt; + const attempt: PreparationAttempt = { + id: snapshot.id, + triggerMessageId: snapshot.triggerMessageId, + status: snapshot.status, + startedAt: snapshot.startedAt, + ...(snapshot.completedAt === undefined ? {} : { completedAt: snapshot.completedAt }), + ...(snapshot.safeError === undefined ? {} : { safeError: snapshot.safeError }), + revision: snapshot.revision, + steps: existing?.steps ?? [], + }; + preparationAttempts = [ + ...preparationAttempts.filter(item => item.id !== attempt.id), + attempt, + ].sort((a, b) => a.startedAt - b.startedAt); + return attempt; + } + if (!existing) return null; + if (event.action === 'attempt_completed' || event.action === 'attempt_failed') { + if (existing.revision >= event.revision || existing.status !== 'running') return null; + const status = event.action === 'attempt_completed' ? 'completed' : 'failed'; + const attempt: PreparationAttempt = { + ...existing, + status, + completedAt: event.timestamp, + ...(event.safeError === undefined ? {} : { safeError: event.safeError }), + revision: event.revision, + steps: settleRunningSteps(existing.steps, status, event.safeError), + }; + preparationAttempts = preparationAttempts.map(item => + item.id === existing.id ? attempt : item + ); + return attempt; + } + if (!event.stepId) return null; + const existingStep = existing.steps.find(step => step.id === event.stepId); + let nextStep: PreparationStepSnapshot | undefined; + if (event.action === 'step_snapshot' && event.stepSnapshot) { + if (existingStep && existingStep.revision > event.stepSnapshot.revision) return null; + nextStep = event.stepSnapshot; + } else if (event.action === 'step_started' && event.kind && event.label) { + if (existingStep && existingStep.revision >= event.revision) return null; + nextStep = { + id: event.stepId, + key: event.step, + kind: event.kind, + label: event.label, + status: 'running', + startedAt: event.timestamp, + revision: event.revision, + ...(event.command === undefined ? {} : { command: event.command }), + ...(event.commandIndex === undefined ? {} : { commandIndex: event.commandIndex }), + ...(event.commandCount === undefined ? {} : { commandCount: event.commandCount }), + }; + } else if ( + existingStep && + existingStep.revision < event.revision && + existingStep.status === 'running' + ) { + if (event.action === 'step_progress' && event.detail !== undefined) { + nextStep = { ...existingStep, latestDetail: event.detail, revision: event.revision }; + } else if (event.action === 'step_output' && event.output !== undefined) { + nextStep = { + ...existingStep, + outputTail: `${existingStep.outputTail ?? ''}${event.output}`, + revision: event.revision, + }; + } else if (event.action === 'step_completed') { + nextStep = { + ...existingStep, + status: 'completed', + completedAt: event.timestamp, + ...(event.exitCode === undefined ? {} : { exitCode: event.exitCode }), + revision: event.revision, + }; + } else if (event.action === 'step_failed' && event.safeError !== undefined) { + nextStep = { + ...existingStep, + status: 'failed', + completedAt: event.timestamp, + safeError: event.safeError, + ...(event.exitCode === undefined ? {} : { exitCode: event.exitCode }), + revision: event.revision, + }; + } + } + if (!nextStep) return null; + const step = nextStep; + const attempt: PreparationAttempt = { + ...existing, + revision: Math.max(existing.revision, step.revision), + steps: [...existing.steps.filter(item => item.id !== step.id), step].sort( + (a, b) => a.startedAt - b.startedAt + ), + }; + preparationAttempts = preparationAttempts.map(item => + item.id === existing.id ? attempt : item + ); + return attempt; + } + function processAutocommitStarted( event: Extract ): void { @@ -504,6 +700,8 @@ function createServiceState(config: ServiceStateConfig): ServiceState { getActivity: () => activity, getStatus: () => status, getCloudStatus: () => cloudStatus, + getSetupLog: () => setupLog, + getPreparationAttempts: () => preparationAttempts, getQuestion: () => question, getPermission: () => permission, getSuggestion: () => suggestion, @@ -514,6 +712,8 @@ function createServiceState(config: ServiceStateConfig): ServiceState { activity, status, cloudStatus, + setupLog, + preparationAttempts, sessionInfo, question, permission, @@ -547,6 +747,8 @@ function createServiceState(config: ServiceStateConfig): ServiceState { activity = INITIAL_ACTIVITY; status = IDLE_STATUS; cloudStatus = null; + setupLog = []; + preparationAttempts = []; sessionInfo = null; question = null; permission = null; diff --git a/apps/web/src/lib/cloud-agent-sdk/session-manager.test.ts b/apps/web/src/lib/cloud-agent-sdk/session-manager.test.ts index b0d706f9f3..f663985ff3 100644 --- a/apps/web/src/lib/cloud-agent-sdk/session-manager.test.ts +++ b/apps/web/src/lib/cloud-agent-sdk/session-manager.test.ts @@ -47,6 +47,7 @@ const mockSession = { getActivity: jest.fn((): SessionActivity => ({ type: 'idle' })), getStatus: jest.fn<{ type: 'idle' | 'disconnected' }, []>(() => ({ type: 'idle' })), getCloudStatus: jest.fn(() => null), + getSetupLog: jest.fn(() => []), getQuestion: jest.fn(() => null), getSessionInfo: jest.fn(() => null), getPermission: jest.fn(() => null), @@ -314,6 +315,7 @@ describe('createSessionManager', () => { }); mockSession.state.getStatus.mockReturnValue({ type: 'idle' }); mockSession.state.getCloudStatus.mockReturnValue(null); + mockSession.state.getSetupLog.mockReturnValue([]); mockSession.state.getPendingMessages.mockReturnValue(new Map()); mockSession.storage = latestStorage; latestStorage = null; @@ -539,6 +541,27 @@ describe('createSessionManager', () => { ); }); + it('exposes setup output and clears it when the manager is destroyed', async () => { + mockSession.state.getSetupLog.mockReturnValue([ + 'Running setup command 1 of 1: pnpm install', + 'Packages: +42', + ]); + + const config = createMockConfig(); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + + expect(atomValue(config.store, mgr.atoms.setupLog)).toEqual([ + 'Running setup command 1 of 1: pnpm install', + 'Packages: +42', + ]); + + mgr.destroy(); + + expect(atomValue(config.store, mgr.atoms.setupLog)).toEqual([]); + }); + it('clears cloud status indicator when cloud status returns to ready', async () => { let subscriptionCallback = (): void => { throw new Error('Expected service state subscription callback'); diff --git a/apps/web/src/lib/cloud-agent-sdk/session-manager.ts b/apps/web/src/lib/cloud-agent-sdk/session-manager.ts index 9b823f2326..b447bd4091 100644 --- a/apps/web/src/lib/cloud-agent-sdk/session-manager.ts +++ b/apps/web/src/lib/cloud-agent-sdk/session-manager.ts @@ -35,6 +35,7 @@ import type { MessageDeliveryState, MessageInfo, Part, + PreparationAttempt, } from './types'; import type { QuestionInfo } from '@/types/opencode.gen'; import { splitByContiguousPrefix } from './array-utils'; @@ -198,6 +199,8 @@ type SessionManagerAtoms = { activity: W; agentStatus: W; cloudStatus: W; + setupLog: W; + preparationAttempts: W; sessionConfig: W; sessionType: W; chatUI: W<{ shouldAutoScroll: boolean }>; @@ -371,6 +374,8 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { const activityAtom = atom({ type: 'connecting' }); const agentStatusAtom = atom({ type: 'idle' }); const cloudStatusAtom = atom(null); + const setupLogAtom = atom([]); + const preparationAttemptsAtom = atom([]); const sessionConfigAtom = atom(null); const sessionTypeAtom = atom(null); const chatUIAtom = atom<{ shouldAutoScroll: boolean }>({ shouldAutoScroll: true }); @@ -492,6 +497,8 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { store.set(activityAtom, { type: 'connecting' }); store.set(agentStatusAtom, { type: 'idle' }); store.set(cloudStatusAtom, null); + store.set(setupLogAtom, []); + store.set(preparationAttemptsAtom, []); store.set(sessionConfigAtom, null); store.set(sessionTypeAtom, null); store.set(activeQuestionAtom, null); @@ -686,6 +693,11 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { } store.set(agentStatusAtom, st); store.set(cloudStatusAtom, cs); + store.set(setupLogAtom, session.state.getSetupLog()); + store.set( + preparationAttemptsAtom, + 'getPreparationAttempts' in session.state ? session.state.getPreparationAttempts() : [] + ); store.set(isStreamingAtom, act.type === 'busy'); store.set(questionAtom, session.state.getQuestion()); store.set(permissionAtom, session.state.getPermission()); @@ -1196,6 +1208,8 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { activity: activityAtom, agentStatus: agentStatusAtom, cloudStatus: cloudStatusAtom, + setupLog: setupLogAtom, + preparationAttempts: preparationAttemptsAtom, sessionConfig: sessionConfigAtom, sessionType: sessionTypeAtom, chatUI: chatUIAtom, diff --git a/apps/web/src/lib/cloud-agent-sdk/types.ts b/apps/web/src/lib/cloud-agent-sdk/types.ts index fa92cba257..47871c052e 100644 --- a/apps/web/src/lib/cloud-agent-sdk/types.ts +++ b/apps/web/src/lib/cloud-agent-sdk/types.ts @@ -138,11 +138,48 @@ export type MessageDeliveryState = attempts?: number; }; +export type PreparationAttemptStatus = 'running' | 'completed' | 'failed'; +export type PreparationStepKind = 'phase' | 'setup_command'; +export type PreparationStepStatus = 'running' | 'completed' | 'failed'; + +export type PreparationStepSnapshot = { + id: string; + key: string; + kind: PreparationStepKind; + label: string; + status: PreparationStepStatus; + startedAt: number; + completedAt?: number; + revision: number; + latestDetail?: string; + safeError?: string; + command?: string; + commandIndex?: number; + commandCount?: number; + outputTail?: string; + outputTruncated?: boolean; + exitCode?: number; +}; + +export type PreparationAttempt = { + id: string; + triggerMessageId: string; + status: PreparationAttemptStatus; + startedAt: number; + completedAt?: number; + safeError?: string; + revision: number; + steps: PreparationStepSnapshot[]; +}; + /** Full service state — all non-chat state in one place. */ export type ServiceStateSnapshot = { activity: SessionActivity; status: AgentStatus; cloudStatus: CloudStatus | null; + /** @deprecated Legacy transient setup output. v2 preparation uses preparationAttempts. */ + setupLog: readonly string[]; + preparationAttempts: readonly PreparationAttempt[]; sessionInfo: SessionInfo | null; question: QuestionState | null; permission: PermissionState | null; diff --git a/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts b/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts index dfad3a19b4..9f8b5322ac 100644 --- a/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts +++ b/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts @@ -524,6 +524,9 @@ export class CloudflareAgentSandbox implements AgentSandbox { const { sessionId, userId, orgId } = plan.scope; this.sandboxIdPromise = Promise.resolve(plan.workspace.sandboxId as SandboxId); const sandboxId = await this.resolveSandboxId(); + // Surface sandbox acquisition — often the longest silent stretch of a cold + // start — as the first step of the preparation attempt. + request.onProgress?.('sandbox_provision', 'Provisioning sandbox…'); const sandbox = await this.getSandbox({ sleepAfter: SANDBOX_SLEEP_AFTER_SECONDS }); if (requiresContainmentSandbox(this.metadata)) { if (sandboxId.startsWith('dind-')) { @@ -630,7 +633,10 @@ export class CloudflareAgentSandbox implements AgentSandbox { inspectContainers: sandboxId.startsWith('dind-'), }); } - request.onProgress?.('kilo_server', 'Starting Kilo...'); + // Not 'kilo_server': this step boots the wrapper process inside the + // sandbox; the wrapper reports the real "Starting Kilo" phase itself at + // the end of its bootstrap. + request.onProgress?.('sandbox_boot', 'Starting sandbox agent...'); const bootstrapSession = await sandbox.createSession({ name: `${sessionId}-bootstrap`, env: {}, diff --git a/services/cloud-agent-next/src/execution/orchestrator.test.ts b/services/cloud-agent-next/src/execution/orchestrator.test.ts index c6d79a922d..30c16c8de0 100644 --- a/services/cloud-agent-next/src/execution/orchestrator.test.ts +++ b/services/cloud-agent-next/src/execution/orchestrator.test.ts @@ -264,6 +264,26 @@ describe('ExecutionOrchestrator AgentSandbox delivery', () => { } satisfies Partial); }); + it('preserves non-retryable workspace setup failures during wrapper startup', async () => { + const { orchestrator, ensureWrapper } = createOrchestrator(); + ensureWrapper.mockRejectedValueOnce( + new WrapperError('Setup command 2 failed', 'WORKSPACE_SETUP_FAILED', 503, { + workspaceFailureSubtype: 'setup_command_failed', + safeDetail: + 'command: pip install, termination: nonzero exit, exit code: 127, output:\nsh: 1: pip: not found', + retryable: false, + }) + ); + + await expect(orchestrator.execute(basePlan)).rejects.toMatchObject({ + code: 'WORKSPACE_SETUP_FAILED', + retryable: false, + workspaceFailureSubtype: 'setup_command_failed', + safeFailureMessage: + 'command: pip install, termination: nonzero exit, exit code: 127, output:\nsh: 1: pip: not found', + } satisfies Partial); + }); + it('keeps ordinary wrapper bootstrap failure retryable', async () => { const { orchestrator, ensureWrapper } = createOrchestrator(); ensureWrapper.mockRejectedValueOnce(new Error('wrapper unavailable')); diff --git a/services/cloud-agent-next/src/execution/orchestrator.ts b/services/cloud-agent-next/src/execution/orchestrator.ts index 3b3241c775..6519a88205 100644 --- a/services/cloud-agent-next/src/execution/orchestrator.ts +++ b/services/cloud-agent-next/src/execution/orchestrator.ts @@ -51,6 +51,26 @@ function withWorkspacePreparationTimeout(operation: Promise, step: string) ); } +function translateKnownWrapperFailure(error: unknown): Error | undefined { + if (error instanceof ExecutionError) return error; + if (!(error instanceof WrapperError)) return undefined; + + if (error.code === 'WORKSPACE_SETUP_FAILED') { + return ExecutionError.workspaceSetupFailed(error.message, error, { + subtype: error.workspaceFailureSubtype, + safeFailureMessage: error.safeDetail, + retryable: error.retryable, + }); + } + if (error.code === 'KILO_SERVER_FAILED') { + return ExecutionError.kiloServerFailed(error.message, error); + } + if (error.code === 'WRAPPER_FINALIZING') { + return error; + } + return undefined; +} + export type OrchestratorDeps = { getAgentSandbox: ( plan: FencedWrapperDispatchRequest | FencedLegacyExecutionRequest @@ -145,8 +165,8 @@ export class ExecutionOrchestrator { }); } catch (error) { await this.destroyEphemeralSandboxAfterPreAcceptanceFailure(sandbox, plan, error); - if (error instanceof ExecutionError) throw error; - if (error instanceof WrapperError && error.code === 'WRAPPER_FINALIZING') throw error; + const knownFailure = translateKnownWrapperFailure(error); + if (knownFailure) throw knownFailure; throw ExecutionError.wrapperStartFailed( `Failed to start wrapper: ${error instanceof Error ? error.message : String(error)}`, error @@ -210,22 +230,8 @@ export class ExecutionOrchestrator { wrapperErrorCode: error instanceof WrapperError ? error.code : undefined, }) .warn('ExecutionOrchestrator wrapper dispatch failed'); - if (error instanceof WrapperError) { - if (error.code === 'WORKSPACE_SETUP_FAILED') { - throw ExecutionError.workspaceSetupFailed(error.message, error, { - subtype: error.workspaceFailureSubtype, - safeFailureMessage: error.safeDetail, - retryable: error.retryable, - }); - } - if (error.code === 'KILO_SERVER_FAILED') { - throw ExecutionError.kiloServerFailed(error.message, error); - } - if (error.code === 'WRAPPER_FINALIZING') { - throw error; - } - } - if (error instanceof ExecutionError) throw error; + const knownFailure = translateKnownWrapperFailure(error); + if (knownFailure) throw knownFailure; throw ExecutionError.wrapperStartFailed( `Failed to execute wrapper bootstrap: ${error instanceof Error ? error.message : String(error)}`, error diff --git a/services/cloud-agent-next/src/execution/types.ts b/services/cloud-agent-next/src/execution/types.ts index de490346b5..9b56279da7 100644 --- a/services/cloud-agent-next/src/execution/types.ts +++ b/services/cloud-agent-next/src/execution/types.ts @@ -317,6 +317,12 @@ type DeliveryRequestBase = { agent: AgentSelection; finalization?: TurnFinalization; workspace: WorkspaceDeliveryPlan; + /** + * Identity of the preparation attempt for this delivery. The DO allocates + * it so that its own early progress (sandbox provisioning, disk checks) and + * the wrapper's bootstrap steps land on one shared attempt. + */ + preparation?: { attemptId: string }; }; /** Durable queued message handed to AgentRuntime before runtime identity allocation. */ diff --git a/services/cloud-agent-next/src/kilo-facade/cloud-agent-extension-events.test.ts b/services/cloud-agent-next/src/kilo-facade/cloud-agent-extension-events.test.ts index f48ae67008..4c766d8c45 100644 --- a/services/cloud-agent-next/src/kilo-facade/cloud-agent-extension-events.test.ts +++ b/services/cloud-agent-next/src/kilo-facade/cloud-agent-extension-events.test.ts @@ -95,6 +95,20 @@ describe('projectPublicCloudAgentExtensionEvent', () => { cloudStatus: { type: 'preparing', step: 'kilo_session' }, }, }); + expect( + projectPublicCloudAgentExtensionEvent( + source('cloud.status', { + cloudStatus: { type: 'preparing', step: 'sandbox_provision' }, + }), + kiloSessionId + ) + ).toEqual({ + type: 'cloud.status', + properties: { + sessionID: kiloSessionId, + cloudStatus: { type: 'preparing', step: 'sandbox_provision' }, + }, + }); expect( projectPublicCloudAgentExtensionEvent( source('cloud.status', { diff --git a/services/cloud-agent-next/src/kilo-facade/cloud-agent-extension-events.ts b/services/cloud-agent-next/src/kilo-facade/cloud-agent-extension-events.ts index 8ae9c46aa1..de431a09ef 100644 --- a/services/cloud-agent-next/src/kilo-facade/cloud-agent-extension-events.ts +++ b/services/cloud-agent-next/src/kilo-facade/cloud-agent-extension-events.ts @@ -7,6 +7,8 @@ export type PublicCloudAgentStatusStep = | 'branch' | 'devcontainer_setup' | 'setup_commands' + | 'sandbox_provision' + | 'sandbox_boot' | 'kilo_server' | 'kilo_session' | 'ready' @@ -97,6 +99,8 @@ function isCloudStatusStep(value: unknown): value is PublicCloudAgentStatusStep value === 'branch' || value === 'devcontainer_setup' || value === 'setup_commands' || + value === 'sandbox_provision' || + value === 'sandbox_boot' || value === 'kilo_server' || value === 'kilo_session' || value === 'ready' || diff --git a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts index 001c1eefb2..f8feb88043 100644 --- a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts +++ b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts @@ -53,6 +53,11 @@ import { type StreamHandler, type QueuedMessageSnapshot, } from '../websocket/stream.js'; +import { + getPreparationSnapshots, + reconcileStalePreparationAttempts, +} from '../session/preparation-history.js'; +import { createPreparationProgressRecorder } from '../session/preparation-progress.js'; import { createIngestHandler, type IngestHandler, @@ -795,6 +800,14 @@ export class CloudAgentSession extends DurableObject { this.streamHandler = createStreamHandler(this.ctx, this.eventQueries, sessionId, { deriveCloudStatus: () => this.deriveCloudStatus(), deriveQueuedMessages: () => this.deriveQueuedMessages(), + getPreparationSnapshots: async () => { + const metadata = await this.getMetadata(); + reconcileStalePreparationAttempts(this.eventQueries, { + now: Date.now(), + sessionPrepared: Boolean(metadata?.lifecycle.preparedAt), + }); + return getPreparationSnapshots(this.eventQueries); + }, getAvailableCommands: () => this.getAvailableCommands(), }); this.streamHandlerSessionId = sessionId; @@ -3164,35 +3177,61 @@ export class CloudAgentSession extends DurableObject { await this.scheduleAlarmAtOrBefore(Date.now() + PENDING_FLUSH_DEBOUNCE_MS); - const result = await this.getAgentRuntime().send(plan, { - onProgress: (step, message) => { - const now = Date.now(); - this.broadcastVolatileEvent({ - executionId: eventSourceId, - sessionId, - streamEventType: 'preparing', - payload: JSON.stringify({ step, message }), - timestamp: now, - }); + const recorder = createPreparationProgressRecorder({ + attemptId: crypto.randomUUID(), + triggerMessageId: plan.turn.messageId, + sessionId, + eventQueries: this.eventQueries, + broadcast: event => this.broadcastVolatileEvent({ executionId: eventSourceId, sessionId, - streamEventType: 'cloud.status', - payload: JSON.stringify({ - cloudStatus: { type: 'preparing' as const, step, message }, - }), - timestamp: now, - }); - }, - onWorkspaceReady: async ready => { - const readyResult = await this.recordSessionReady(ready); - if (!readyResult.success) { - throw new Error(readyResult.error ?? 'Failed to record session readiness'); - } - }, - onAccepted: delivery => this.recordRuntimeAcceptedMessage(plan, delivery), + streamEventType: event.stream_event_type, + payload: event.payload, + timestamp: event.timestamp, + }), }); + let result: MessageDeliveryResult; + try { + result = await this.getAgentRuntime().send( + { ...plan, preparation: { attemptId: recorder.attemptId } }, + { + onProgress: (step, message) => { + recorder.onProgress(step, message); + this.broadcastVolatileEvent({ + executionId: eventSourceId, + sessionId, + streamEventType: 'cloud.status', + payload: JSON.stringify({ + cloudStatus: { type: 'preparing' as const, step, message }, + }), + timestamp: Date.now(), + }); + }, + onWorkspaceReady: async ready => { + const readyResult = await this.recordSessionReady(ready); + if (!readyResult.success) { + throw new Error(readyResult.error ?? 'Failed to record session readiness'); + } + }, + onAccepted: delivery => this.recordRuntimeAcceptedMessage(plan, delivery), + } + ); + } catch (error) { + recorder.finalize({ status: 'failed', safeError: 'Environment preparation failed' }); + throw error; + } + + // The wrapper's own terminal event can be lost (its progress channel may + // drop before delivery), so settle the attempt from the delivery outcome: + // an accepted message proves preparation finished. + recorder.finalize( + result.success + ? { status: 'completed' } + : { status: 'failed', safeError: 'Environment preparation failed' } + ); + this.broadcastVolatileEvent({ executionId: eventSourceId, sessionId, diff --git a/services/cloud-agent-next/src/session-service.test.ts b/services/cloud-agent-next/src/session-service.test.ts index 6a46a5215f..6d7e678dc1 100644 --- a/services/cloud-agent-next/src/session-service.test.ts +++ b/services/cloud-agent-next/src/session-service.test.ts @@ -1658,7 +1658,8 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { async function buildPromptWrapperRequests( metadata: CloudAgentSessionState, - customizeEnv?: (env: PersistenceEnv) => void + customizeEnv?: (env: PersistenceEnv) => void, + preparation?: { attemptId: string } ) { const service = new SessionService(); const env = createEnv(); @@ -1668,6 +1669,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { return service.buildWrapperSessionReadyAndPromptRequests({ env, plan: { + ...(preparation ? { preparation } : {}), scope: { sessionId: 'agent_test', userId: 'user_test', @@ -1993,6 +1995,14 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { expect(JSON.stringify(result.readyRequest)).not.toContain('resolved-gitlab-token'); }); + it('continues the preparation attempt the delivery plan allocated', async () => { + const result = await buildPromptWrapperRequests(createMetadata(), undefined, { + attemptId: 'attempt-from-do', + }); + + expect(result.readyRequest.preparation?.attemptId).toBe('attempt-from-do'); + }); + it('uses direct GitLab authentication for a resumed DIND session', async () => { const result = await buildPromptWrapperRequests({ ...createMetadata({ preparedAt: 1 }), @@ -2148,6 +2158,9 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { materialized: { setupCommands: ['pnpm install'], }, + preparation: { + triggerMessageId: 'msg_018f1e2d3c4bPayloadTestAAAA', + }, }); expect(result.readyRequest).not.toHaveProperty('prompt'); expect(result.type).toBe('prompt'); diff --git a/services/cloud-agent-next/src/session-service.ts b/services/cloud-agent-next/src/session-service.ts index 02b9d6a26f..80e31dd7fc 100644 --- a/services/cloud-agent-next/src/session-service.ts +++ b/services/cloud-agent-next/src/session-service.ts @@ -2061,6 +2061,7 @@ export class SessionService { : []; const promptAgent = normalizeAgentMode(agent.mode); + const preferSnapshot = metadata.lifecycle.preparedAt !== undefined; const readyRequest: WrapperSessionReadyRequest = { agentSessionId: sessionId, userId, @@ -2077,7 +2078,7 @@ export class SessionService { strictBranch: Boolean( metadata.repository?.upstreamBranch && !metadata.lifecycle.preparedAt ), - preferSnapshot: metadata.lifecycle.preparedAt !== undefined, + preferSnapshot, }, ...(repo ? { repo } : {}), ...(devcontainerRequested @@ -2094,6 +2095,13 @@ export class SessionService { ...(profile.runtimeSkills?.length ? { runtimeSkills: profile.runtimeSkills } : {}), }, session, + preparation: { + // Reuse the attempt the DO allocated (and may already have started + // with early sandbox-provisioning steps) so the wrapper's bootstrap + // events continue the same attempt instead of opening a second one. + attemptId: plan.preparation?.attemptId ?? crypto.randomUUID(), + triggerMessageId: turn.messageId, + }, }; if (turn.type === 'command') { diff --git a/services/cloud-agent-next/src/session/preparation-history.test.ts b/services/cloud-agent-next/src/session/preparation-history.test.ts new file mode 100644 index 0000000000..f90a0ce17a --- /dev/null +++ b/services/cloud-agent-next/src/session/preparation-history.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it } from 'vitest'; +import { + cloudStatusForPreparingEvent, + finalizePreparationAttempt, + getPreparationSnapshots, + materializePreparationEvent, + readPreparationAttempt, + reconcileStalePreparationAttempts, +} from './preparation-history.js'; +import { + createMemoryEventQueries, + readAttempt, + readStep, + seedRunningAttempt, + storedEvent, +} from './preparation-test-helpers.js'; + +const MINUTE_MS = 60 * 1000; + +describe('materializePreparationEvent', () => { + it('preserves startedAt when attempt_started re-announces a running attempt', () => { + const eventQueries = createMemoryEventQueries(); + const { attemptId } = seedRunningAttempt(eventQueries, { startedAt: 1000 }); + + const applied = materializePreparationEvent(eventQueries, storedEvent(60_000), { + version: 2, + attemptId, + triggerMessageId: 'msg-1', + revision: 50_000, + timestamp: 60_000, + step: 'workspace_setup', + message: 'Preparing environment', + action: 'attempt_started', + }); + + expect(applied).toBe(true); + const attempt = readAttempt(eventQueries, attemptId); + expect(attempt.startedAt).toBe(1000); + expect(attempt.revision).toBe(50_000); + }); + + it("settles the previous emitter's running steps when the wrapper takes over", () => { + const eventQueries = createMemoryEventQueries(); + const { attemptId, lastEventAt } = seedRunningAttempt(eventQueries, { startedAt: 1000 }); + + materializePreparationEvent(eventQueries, storedEvent(lastEventAt + 1000), { + version: 2, + attemptId, + triggerMessageId: 'msg-1', + revision: 50_000, + timestamp: lastEventAt + 1000, + step: 'workspace_setup', + message: 'Preparing environment', + action: 'attempt_started', + }); + + const step = readStep(eventQueries, attemptId, 'phase:kilo_server'); + expect(step.status).toBe('completed'); + expect(step.completedAt).toBe(lastEventAt + 1000); + }); + + it('settles dangling running steps when the attempt fails', () => { + const eventQueries = createMemoryEventQueries(); + const { attemptId, lastEventAt } = seedRunningAttempt(eventQueries, { startedAt: 1000 }); + + materializePreparationEvent(eventQueries, storedEvent(lastEventAt + 1000), { + version: 2, + attemptId, + triggerMessageId: 'msg-1', + revision: 50_000, + timestamp: lastEventAt + 1000, + step: 'failed', + message: 'Setup command failed', + action: 'attempt_failed', + safeError: 'Setup command failed', + }); + + const step = readStep(eventQueries, attemptId, 'phase:kilo_server'); + expect(step.status).toBe('failed'); + expect(step.safeError).toBe('Setup command failed'); + }); + + it('restarts a terminal attempt with a fresh startedAt', () => { + const eventQueries = createMemoryEventQueries(); + const { attemptId, lastEventAt } = seedRunningAttempt(eventQueries, { startedAt: 1000 }); + finalizePreparationAttempt(eventQueries, attemptId, { + status: 'failed', + safeError: 'boom', + timestamp: lastEventAt, + }); + const failedRevision = readAttempt(eventQueries, attemptId).revision; + + materializePreparationEvent(eventQueries, storedEvent(90_000), { + version: 2, + attemptId, + triggerMessageId: 'msg-1', + revision: failedRevision + 1, + timestamp: 90_000, + step: 'workspace_setup', + message: 'Preparing environment', + action: 'attempt_started', + }); + + const attempt = readAttempt(eventQueries, attemptId); + expect(attempt.status).toBe('running'); + expect(attempt.startedAt).toBe(90_000); + }); +}); + +describe('finalizePreparationAttempt', () => { + it('completes a running attempt and its running steps and returns the events', () => { + const eventQueries = createMemoryEventQueries(); + const { attemptId } = seedRunningAttempt(eventQueries); + + const events = finalizePreparationAttempt(eventQueries, attemptId, { + status: 'completed', + timestamp: 9000, + }); + + const attempt = readAttempt(eventQueries, attemptId); + expect(attempt.status).toBe('completed'); + expect(attempt.completedAt).toBe(9000); + const step = readStep(eventQueries, attemptId, 'phase:kilo_server'); + expect(step.status).toBe('completed'); + expect(step.completedAt).toBe(9000); + expect(events.map(event => (JSON.parse(event.payload) as { action: string }).action)).toEqual([ + 'step_completed', + 'attempt_completed', + ]); + }); + + it('fails a running attempt with the given safe error', () => { + const eventQueries = createMemoryEventQueries(); + const { attemptId } = seedRunningAttempt(eventQueries); + + const events = finalizePreparationAttempt(eventQueries, attemptId, { + status: 'failed', + safeError: 'Environment preparation failed', + timestamp: 9000, + }); + + const attempt = readAttempt(eventQueries, attemptId); + expect(attempt.status).toBe('failed'); + expect(attempt.safeError).toBe('Environment preparation failed'); + expect(readStep(eventQueries, attemptId, 'phase:kilo_server').status).toBe('failed'); + expect(events).toHaveLength(2); + }); + + it('is a no-op for terminal and unknown attempts', () => { + const eventQueries = createMemoryEventQueries(); + const { attemptId } = seedRunningAttempt(eventQueries); + finalizePreparationAttempt(eventQueries, attemptId, { status: 'completed', timestamp: 9000 }); + const before = readAttempt(eventQueries, attemptId); + + expect( + finalizePreparationAttempt(eventQueries, attemptId, { status: 'completed', timestamp: 9999 }) + ).toEqual([]); + expect( + finalizePreparationAttempt(eventQueries, 'missing', { status: 'completed', timestamp: 9999 }) + ).toEqual([]); + expect(readAttempt(eventQueries, attemptId)).toEqual(before); + }); +}); + +describe('readPreparationAttempt', () => { + it('returns the materialized attempt snapshot or null', () => { + const eventQueries = createMemoryEventQueries(); + expect(readPreparationAttempt(eventQueries, 'attempt-1')).toBeNull(); + const { attemptId } = seedRunningAttempt(eventQueries); + expect(readPreparationAttempt(eventQueries, attemptId)?.status).toBe('running'); + }); +}); + +describe('reconcileStalePreparationAttempts', () => { + it('completes a stale running attempt and its running steps on a prepared session', () => { + const eventQueries = createMemoryEventQueries(); + const { attemptId, lastEventAt } = seedRunningAttempt(eventQueries); + + reconcileStalePreparationAttempts(eventQueries, { + now: lastEventAt + 16 * MINUTE_MS, + sessionPrepared: true, + }); + + const attempt = readAttempt(eventQueries, attemptId); + expect(attempt.status).toBe('completed'); + expect(attempt.completedAt).toBe(lastEventAt); + expect(attempt.safeError).toBeUndefined(); + const step = readStep(eventQueries, attemptId, 'phase:kilo_server'); + expect(step.status).toBe('completed'); + expect(step.completedAt).toBe(lastEventAt); + }); + + it('fails a stale running attempt when the session never became prepared', () => { + const eventQueries = createMemoryEventQueries(); + const { attemptId, lastEventAt } = seedRunningAttempt(eventQueries); + + reconcileStalePreparationAttempts(eventQueries, { + now: lastEventAt + 16 * MINUTE_MS, + sessionPrepared: false, + }); + + const attempt = readAttempt(eventQueries, attemptId); + expect(attempt.status).toBe('failed'); + expect(attempt.safeError).toBe('Preparation did not complete'); + expect(readStep(eventQueries, attemptId, 'phase:kilo_server').status).toBe('failed'); + }); + + it('leaves a recently updated running attempt untouched', () => { + const eventQueries = createMemoryEventQueries(); + const { attemptId, lastEventAt } = seedRunningAttempt(eventQueries); + + reconcileStalePreparationAttempts(eventQueries, { + now: lastEventAt + 5 * MINUTE_MS, + sessionPrepared: true, + }); + + expect(readAttempt(eventQueries, attemptId).status).toBe('running'); + expect(readStep(eventQueries, attemptId, 'phase:kilo_server').status).toBe('running'); + }); + + it('leaves terminal attempts untouched', () => { + const eventQueries = createMemoryEventQueries(); + const { attemptId, lastEventAt } = seedRunningAttempt(eventQueries); + materializePreparationEvent(eventQueries, storedEvent(lastEventAt + 1000), { + version: 2, + attemptId, + triggerMessageId: 'msg-1', + revision: 3, + timestamp: lastEventAt + 1000, + step: 'ready', + message: 'Preparation complete', + action: 'attempt_completed', + }); + const before = readAttempt(eventQueries, attemptId); + + reconcileStalePreparationAttempts(eventQueries, { + now: lastEventAt + 60 * MINUTE_MS, + sessionPrepared: true, + }); + + expect(readAttempt(eventQueries, attemptId)).toEqual(before); + }); + + it('bumps revisions above the stale snapshots so clients apply the repair', () => { + const eventQueries = createMemoryEventQueries(); + const { attemptId, lastEventAt } = seedRunningAttempt(eventQueries); + const staleRevision = readAttempt(eventQueries, attemptId).revision; + + reconcileStalePreparationAttempts(eventQueries, { + now: lastEventAt + 16 * MINUTE_MS, + sessionPrepared: true, + }); + + const attempt = readAttempt(eventQueries, attemptId); + expect(attempt.revision).toBeGreaterThan(staleRevision); + const step = readStep(eventQueries, attemptId, 'phase:kilo_server'); + expect(step.revision).toBeGreaterThan(staleRevision); + expect(step.revision).toBeLessThanOrEqual(attempt.revision); + const snapshots = getPreparationSnapshots(eventQueries); + expect(snapshots).toHaveLength(2); + }); +}); + +describe('cloudStatusForPreparingEvent', () => { + const v2 = { + version: 2, + attemptId: 'attempt-1', + triggerMessageId: 'msg-1', + revision: 5, + timestamp: 1000, + }; + + it('maps applied v2 events by action', () => { + expect( + cloudStatusForPreparingEvent( + { ...v2, step: 'cloning', message: 'Cloning…', action: 'step_progress' }, + true + ) + ).toEqual({ type: 'preparing', step: 'cloning', message: 'Cloning…' }); + expect( + cloudStatusForPreparingEvent( + { ...v2, step: 'ready', message: 'Preparation complete', action: 'attempt_completed' }, + true + ) + ).toEqual({ type: 'ready' }); + expect( + cloudStatusForPreparingEvent( + { ...v2, step: 'failed', message: 'nope', action: 'attempt_failed', safeError: 'boom' }, + true + ) + ).toEqual({ type: 'error', message: 'boom' }); + }); + + it('suppresses the broadcast for stale v2 events', () => { + expect( + cloudStatusForPreparingEvent( + { ...v2, step: 'cloning', message: 'Cloning…', action: 'step_progress' }, + false + ) + ).toBeNull(); + expect( + cloudStatusForPreparingEvent( + { ...v2, step: 'ready', message: 'Preparation complete', action: 'attempt_completed' }, + false + ) + ).toBeNull(); + }); + + it('maps legacy v1 events by step', () => { + expect(cloudStatusForPreparingEvent({ step: 'cloning', message: 'Cloning…' }, false)).toEqual({ + type: 'preparing', + step: 'cloning', + message: 'Cloning…', + }); + expect(cloudStatusForPreparingEvent({ step: 'ready', message: 'Done' }, false)).toEqual({ + type: 'ready', + }); + expect(cloudStatusForPreparingEvent({ step: 'failed', message: 'nope' }, false)).toEqual({ + type: 'error', + message: 'nope', + }); + expect(cloudStatusForPreparingEvent('not-an-object', false)).toBeNull(); + }); +}); diff --git a/services/cloud-agent-next/src/session/preparation-history.ts b/services/cloud-agent-next/src/session/preparation-history.ts new file mode 100644 index 0000000000..3a3cb3ff66 --- /dev/null +++ b/services/cloud-agent-next/src/session/preparation-history.ts @@ -0,0 +1,439 @@ +import type { + CloudStatusData, + PreparationAttempt, + PreparationStepSnapshot, + PreparingEventDataV2, +} from '../shared/protocol.js'; +import type { EventQueries } from './queries/index.js'; +import type { StoredEvent } from '../websocket/types.js'; +import type { EventId } from '../types/ids.js'; + +const OUTPUT_TAIL_MAX_BYTES = 65_536; + +type PreparationSnapshot = + | { action: 'attempt_snapshot'; attempt: Omit } + | { action: 'step_snapshot'; stepSnapshot: PreparationStepSnapshot }; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isPreparationEvent(data: unknown): data is PreparingEventDataV2 { + return ( + isRecord(data) && + data.version === 2 && + typeof data.attemptId === 'string' && + typeof data.triggerMessageId === 'string' && + typeof data.revision === 'number' && + typeof data.timestamp === 'number' && + typeof data.step === 'string' && + typeof data.message === 'string' && + typeof data.action === 'string' + ); +} + +function parseSnapshot(payload: string): PreparationSnapshot | null { + try { + const data: unknown = JSON.parse(payload); + if (!isRecord(data)) return null; + if (data.action === 'attempt_snapshot' && isRecord(data.attempt)) { + return { action: data.action, attempt: data.attempt as Omit }; + } + if (data.action === 'step_snapshot' && isRecord(data.stepSnapshot)) { + return { action: data.action, stepSnapshot: data.stepSnapshot as PreparationStepSnapshot }; + } + } catch { + return null; + } + return null; +} + +function utf8Tail(text: string, maxBytes: number): { text: string; truncated: boolean } { + const bytes = new TextEncoder().encode(text); + if (bytes.length <= maxBytes) return { text, truncated: false }; + let start = bytes.length - maxBytes; + while (start < bytes.length && (bytes[start] & 0b1100_0000) === 0b1000_0000) start++; + return { text: new TextDecoder().decode(bytes.subarray(start)), truncated: true }; +} + +function isTerminal( + status: PreparationAttempt['status'] | PreparationStepSnapshot['status'] +): boolean { + return status === 'completed' || status === 'failed'; +} + +function attemptEntityId(attemptId: string): string { + return `preparation/attempt/${attemptId}`; +} + +function stepEntityId(attemptId: string, stepId: string): string { + return `${attemptEntityId(attemptId)}/step/${stepId}`; +} + +function writeSnapshot( + eventQueries: EventQueries, + event: Pick, + entityId: string, + attempt: Omit, + snapshot: PreparationSnapshot +): void { + const step = snapshot.action === 'step_snapshot' ? snapshot.stepSnapshot.key : 'workspace_setup'; + eventQueries.upsert({ + executionId: event.execution_id, + sessionId: event.session_id, + streamEventType: 'preparing', + payload: JSON.stringify({ + version: 2, + attemptId: attempt.id, + triggerMessageId: attempt.triggerMessageId, + revision: + snapshot.action === 'step_snapshot' + ? snapshot.stepSnapshot.revision + : snapshot.attempt.revision, + timestamp: event.timestamp, + step, + message: 'Preparation snapshot', + ...(snapshot.action === 'step_snapshot' ? { stepId: snapshot.stepSnapshot.id } : {}), + ...snapshot, + }), + timestamp: event.timestamp, + entityId, + }); +} + +/** + * Preparation steps come from two independent emitters (the DO before the + * wrapper boots, the wrapper after), and each only completes its own previous + * step. Whenever the attempt changes hands or reaches a terminal state, any + * step still marked running has lost its emitter — settle it so it does not + * spin forever. + */ +function settleRunningSteps( + eventQueries: EventQueries, + event: Pick, + attempt: Omit, + data: { timestamp: number; revision: number }, + outcome: { status: 'completed' } | { status: 'failed'; safeError: string } +): void { + for (const stepRow of eventQueries.findByEntityPrefix(`${attemptEntityId(attempt.id)}/step/`)) { + const snapshot = parseSnapshot(stepRow.payload); + if (snapshot?.action !== 'step_snapshot') continue; + const step = snapshot.stepSnapshot; + if (step.status !== 'running') continue; + const settled: PreparationStepSnapshot = { + ...step, + status: outcome.status, + completedAt: data.timestamp, + ...(outcome.status === 'failed' ? { safeError: outcome.safeError } : {}), + revision: data.revision, + }; + writeSnapshot(eventQueries, event, stepEntityId(attempt.id, step.id), attempt, { + action: 'step_snapshot', + stepSnapshot: settled, + }); + } +} + +export function materializePreparationEvent( + eventQueries: EventQueries, + event: Pick, + data: unknown +): boolean { + if (!isPreparationEvent(data) || data.action.endsWith('_snapshot')) return false; + const attemptId = data.attemptId; + const attemptIdKey = attemptEntityId(attemptId); + const existingAttempt = eventQueries.findByEntityId(attemptIdKey); + const existingAttemptSnapshot = existingAttempt ? parseSnapshot(existingAttempt.payload) : null; + let attempt = + existingAttemptSnapshot?.action === 'attempt_snapshot' + ? existingAttemptSnapshot.attempt + : undefined; + + if (data.action === 'attempt_started') { + if (attempt && attempt.revision >= data.revision) return false; + const handoff = attempt?.status === 'running'; + attempt = { + id: attemptId, + triggerMessageId: data.triggerMessageId, + status: 'running', + // The wrapper re-announces the attempt the server already started; + // keep the original start so the duration spans the whole preparation. + startedAt: handoff && attempt ? attempt.startedAt : data.timestamp, + revision: data.revision, + }; + writeSnapshot(eventQueries, event, attemptIdKey, attempt, { + action: 'attempt_snapshot', + attempt, + }); + // A re-announce means a new emitter (the wrapper) took over; the previous + // emitter's active step has no one left to complete it. + if (handoff) { + settleRunningSteps(eventQueries, event, attempt, data, { status: 'completed' }); + } + return true; + } + + if (!attempt || data.revision <= attempt.revision || isTerminal(attempt.status)) return false; + + if (data.action === 'attempt_completed' || data.action === 'attempt_failed') { + attempt = { + ...attempt, + status: data.action === 'attempt_completed' ? 'completed' : 'failed', + completedAt: data.timestamp, + ...(data.action === 'attempt_failed' ? { safeError: data.safeError } : {}), + revision: data.revision, + }; + writeSnapshot(eventQueries, event, attemptIdKey, attempt, { + action: 'attempt_snapshot', + attempt, + }); + settleRunningSteps( + eventQueries, + event, + attempt, + data, + data.action === 'attempt_completed' + ? { status: 'completed' } + : { status: 'failed', safeError: data.safeError } + ); + return true; + } + + if (!('stepId' in data) || typeof data.stepId !== 'string') return false; + const entityId = stepEntityId(attemptId, data.stepId); + const existingStep = eventQueries.findByEntityId(entityId); + const existingStepSnapshot = existingStep ? parseSnapshot(existingStep.payload) : null; + let step = + existingStepSnapshot?.action === 'step_snapshot' + ? existingStepSnapshot.stepSnapshot + : undefined; + + if (data.action === 'step_started') { + if (step && step.revision >= data.revision) return false; + step = { + id: data.stepId, + key: data.step, + kind: data.kind, + label: data.label, + status: 'running', + startedAt: data.timestamp, + revision: data.revision, + ...(data.command === undefined ? {} : { command: data.command }), + ...(data.commandIndex === undefined ? {} : { commandIndex: data.commandIndex }), + ...(data.commandCount === undefined ? {} : { commandCount: data.commandCount }), + }; + } else { + if (!step || data.revision <= step.revision || isTerminal(step.status)) return false; + if (data.action === 'step_progress') { + step = { ...step, latestDetail: data.detail, revision: data.revision }; + } else if (data.action === 'step_output') { + const tail = utf8Tail(`${step.outputTail ?? ''}${data.output}`, OUTPUT_TAIL_MAX_BYTES); + step = { + ...step, + outputTail: tail.text, + outputTruncated: step.outputTruncated === true || tail.truncated, + revision: data.revision, + }; + } else if (data.action === 'step_completed') { + step = { + ...step, + status: 'completed', + completedAt: data.timestamp, + ...(data.exitCode === undefined ? {} : { exitCode: data.exitCode }), + revision: data.revision, + }; + } else if (data.action === 'step_failed') { + step = { + ...step, + status: 'failed', + completedAt: data.timestamp, + safeError: data.safeError, + ...(data.exitCode === undefined ? {} : { exitCode: data.exitCode }), + revision: data.revision, + }; + } else { + return false; + } + } + + const updatedAttempt = { ...attempt, revision: data.revision }; + writeSnapshot(eventQueries, event, entityId, updatedAttempt, { + action: 'step_snapshot', + stepSnapshot: step, + }); + writeSnapshot(eventQueries, event, attemptIdKey, updatedAttempt, { + action: 'attempt_snapshot', + attempt: updatedAttempt, + }); + return true; +} + +const STALE_RUNNING_ATTEMPT_TIMEOUT_MS = 15 * 60 * 1000; + +/** Read the materialized snapshot of one attempt, without its steps. */ +export function readPreparationAttempt( + eventQueries: EventQueries, + attemptId: string +): Omit | null { + const row = eventQueries.findByEntityId(attemptEntityId(attemptId)); + const snapshot = row ? parseSnapshot(row.payload) : null; + return snapshot?.action === 'attempt_snapshot' ? snapshot.attempt : null; +} + +export type PreparationOutcome = { status: 'completed' } | { status: 'failed'; safeError: string }; + +/** + * Drive a still-running attempt (and its running steps) to a terminal state + * by synthesizing the missing v2 events through the materializer. Returns the + * synthesized events as stored rows so callers can broadcast them to live + * stream clients; terminal or unknown attempts yield no events. + */ +export function finalizePreparationAttempt( + eventQueries: EventQueries, + attemptId: string, + options: PreparationOutcome & { timestamp: number } +): StoredEvent[] { + const row = eventQueries.findByEntityId(attemptEntityId(attemptId)); + if (!row) return []; + const snapshot = parseSnapshot(row.payload); + if (snapshot?.action !== 'attempt_snapshot' || snapshot.attempt.status !== 'running') return []; + + const attempt = snapshot.attempt; + let revision = attempt.revision; + const events: StoredEvent[] = []; + const emit = (data: PreparingEventDataV2): void => { + const stored: StoredEvent = { + id: 0 as EventId, + execution_id: row.execution_id, + session_id: row.session_id, + stream_event_type: 'preparing', + payload: JSON.stringify(data), + timestamp: options.timestamp, + }; + if (materializePreparationEvent(eventQueries, stored, data)) events.push(stored); + }; + const base = { + version: 2 as const, + attemptId: attempt.id, + triggerMessageId: attempt.triggerMessageId, + timestamp: options.timestamp, + }; + + for (const stepRow of eventQueries.findByEntityPrefix(`${attemptEntityId(attemptId)}/step/`)) { + const stepSnapshot = parseSnapshot(stepRow.payload); + if (stepSnapshot?.action !== 'step_snapshot') continue; + const step = stepSnapshot.stepSnapshot; + if (step.status !== 'running') continue; + emit( + options.status === 'completed' + ? { + ...base, + revision: ++revision, + step: step.key, + message: 'Preparation complete', + action: 'step_completed', + stepId: step.id, + } + : { + ...base, + revision: ++revision, + step: step.key, + message: options.safeError, + action: 'step_failed', + stepId: step.id, + safeError: options.safeError, + } + ); + } + + emit( + options.status === 'completed' + ? { + ...base, + revision: ++revision, + step: 'ready', + message: 'Preparation complete', + action: 'attempt_completed', + } + : { + ...base, + revision: ++revision, + step: 'failed', + message: options.safeError, + action: 'attempt_failed', + safeError: options.safeError, + } + ); + return events; +} + +/** + * Repair attempts stranded in 'running'. Preparation is hard-capped well + * below this timeout, so a running attempt whose snapshots stopped updating + * this long ago lost its terminal event (e.g. the wrapper's progress channel + * dropped before `attempt_completed` was delivered). Rewrites the + * materialized snapshots so replaying clients see a terminal attempt instead + * of a forever-running one. + */ +export function reconcileStalePreparationAttempts( + eventQueries: EventQueries, + options: { now: number; sessionPrepared: boolean } +): void { + for (const row of eventQueries.findByEntityPrefix('preparation/attempt/')) { + const snapshot = parseSnapshot(row.payload); + if (snapshot?.action !== 'attempt_snapshot') continue; + if (snapshot.attempt.status !== 'running') continue; + if (options.now - row.timestamp < STALE_RUNNING_ATTEMPT_TIMEOUT_MS) continue; + + finalizePreparationAttempt(eventQueries, snapshot.attempt.id, { + ...(options.sessionPrepared + ? { status: 'completed' as const } + : { status: 'failed' as const, safeError: 'Preparation did not complete' }), + timestamp: row.timestamp, + }); + } +} + +/** + * Map a 'preparing' stream event to the `cloud.status` broadcast that should + * accompany it, or null when none should be sent. Stale v2 events (ones the + * materializer rejected) must not regress a ready session back to + * 'preparing' — that strands the chat input in its disabled state. + */ +export function cloudStatusForPreparingEvent( + data: unknown, + applied: boolean +): CloudStatusData['cloudStatus'] | null { + if (!isRecord(data)) return null; + const step = typeof data.step === 'string' ? { step: data.step } : {}; + const message = typeof data.message === 'string' ? { message: data.message } : {}; + if (data.version === 2) { + if (!applied) return null; + if (data.action === 'attempt_completed') return { type: 'ready' }; + if (data.action === 'attempt_failed') { + return { + type: 'error', + ...(typeof data.safeError === 'string' ? { message: data.safeError } : message), + }; + } + return { type: 'preparing', ...step, ...message }; + } + if (data.step === 'ready') return { type: 'ready' }; + if (data.step === 'failed') return { type: 'error', ...message }; + return { type: 'preparing', ...step, ...message }; +} + +export function getPreparationSnapshots(eventQueries: EventQueries): StoredEvent[] { + const rows = eventQueries.findByEntityPrefix('preparation/attempt/'); + const attempts: StoredEvent[] = []; + const steps: StoredEvent[] = []; + for (const row of rows) { + const snapshot = parseSnapshot(row.payload); + if (snapshot?.action === 'attempt_snapshot') attempts.push(row); + if (snapshot?.action === 'step_snapshot') steps.push(row); + } + return [ + ...attempts.sort((a, b) => a.timestamp - b.timestamp), + ...steps.sort((a, b) => a.timestamp - b.timestamp), + ]; +} diff --git a/services/cloud-agent-next/src/session/preparation-progress.test.ts b/services/cloud-agent-next/src/session/preparation-progress.test.ts new file mode 100644 index 0000000000..25134a5eb8 --- /dev/null +++ b/services/cloud-agent-next/src/session/preparation-progress.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; +import { createPreparationProgressRecorder } from './preparation-progress.js'; +import { materializePreparationEvent } from './preparation-history.js'; +import { + createMemoryEventQueries, + readAttempt, + readStep, + storedEvent, +} from './preparation-test-helpers.js'; +import type { EventQueries } from './queries/index.js'; +import type { StoredEvent } from '../websocket/types.js'; + +function createRecorder(eventQueries: EventQueries, broadcasts: StoredEvent[]) { + let tick = 1000; + return createPreparationProgressRecorder({ + attemptId: 'attempt-1', + triggerMessageId: 'msg-1', + sessionId: 'sess-1', + eventQueries, + broadcast: event => broadcasts.push(event), + now: () => tick++, + }); +} + +function broadcastActions(broadcasts: StoredEvent[]): string[] { + return broadcasts.map(event => (JSON.parse(event.payload) as { action: string }).action); +} + +describe('createPreparationProgressRecorder', () => { + it('starts the attempt on first progress and tracks step transitions', () => { + const eventQueries = createMemoryEventQueries(); + const broadcasts: StoredEvent[] = []; + const recorder = createRecorder(eventQueries, broadcasts); + + recorder.onProgress('sandbox_provision', 'Provisioning sandbox…'); + recorder.onProgress('disk_check', 'Checking disk space…'); + + const attempt = readAttempt(eventQueries, 'attempt-1'); + expect(attempt.status).toBe('running'); + expect(readStep(eventQueries, 'attempt-1', 'phase:sandbox_provision').status).toBe('completed'); + const diskCheck = readStep(eventQueries, 'attempt-1', 'phase:disk_check'); + expect(diskCheck.status).toBe('running'); + expect(diskCheck.latestDetail).toBe('Checking disk space…'); + expect(broadcastActions(broadcasts)).toEqual([ + 'attempt_started', + 'step_started', + 'step_progress', + 'step_completed', + 'step_started', + 'step_progress', + ]); + }); + + it('repeated progress on the same step only updates the detail', () => { + const eventQueries = createMemoryEventQueries(); + const broadcasts: StoredEvent[] = []; + const recorder = createRecorder(eventQueries, broadcasts); + + recorder.onProgress('sandbox_provision', 'Provisioning sandbox…'); + recorder.onProgress('sandbox_provision', 'Still provisioning…'); + + const step = readStep(eventQueries, 'attempt-1', 'phase:sandbox_provision'); + expect(step.status).toBe('running'); + expect(step.latestDetail).toBe('Still provisioning…'); + expect(broadcastActions(broadcasts)).toEqual([ + 'attempt_started', + 'step_started', + 'step_progress', + 'step_progress', + ]); + }); + + it('keeps sandbox provisioning distinct from wrapper workspace setup', () => { + const eventQueries = createMemoryEventQueries(); + const broadcasts: StoredEvent[] = []; + const recorder = createRecorder(eventQueries, broadcasts); + recorder.onProgress('sandbox_provision', 'Provisioning sandbox…'); + + const wrapperRevision = 1_750_000_000_000; + materializePreparationEvent(eventQueries, storedEvent(2000), { + version: 2, + attemptId: 'attempt-1', + triggerMessageId: 'msg-1', + revision: wrapperRevision, + timestamp: 2000, + step: 'workspace_setup', + message: 'Preparing environment', + action: 'attempt_started', + }); + materializePreparationEvent(eventQueries, storedEvent(2001), { + version: 2, + attemptId: 'attempt-1', + triggerMessageId: 'msg-1', + revision: wrapperRevision + 1, + timestamp: 2001, + step: 'workspace_setup', + message: 'Setting up workspace', + action: 'step_started', + stepId: 'phase:workspace_setup', + kind: 'phase', + label: 'workspace setup', + }); + + expect(readStep(eventQueries, 'attempt-1', 'phase:sandbox_provision').status).toBe('completed'); + expect(readStep(eventQueries, 'attempt-1', 'phase:workspace_setup').status).toBe('running'); + }); + + it('finalize completes the running attempt and broadcasts the terminal events', () => { + const eventQueries = createMemoryEventQueries(); + const broadcasts: StoredEvent[] = []; + const recorder = createRecorder(eventQueries, broadcasts); + recorder.onProgress('kilo_server', 'Starting Kilo…'); + broadcasts.length = 0; + + recorder.finalize({ status: 'completed' }); + + expect(readAttempt(eventQueries, 'attempt-1').status).toBe('completed'); + expect(readStep(eventQueries, 'attempt-1', 'phase:kilo_server').status).toBe('completed'); + expect(broadcastActions(broadcasts)).toEqual(['step_completed', 'attempt_completed']); + }); + + it('finalize marks the attempt failed with the safe error', () => { + const eventQueries = createMemoryEventQueries(); + const broadcasts: StoredEvent[] = []; + const recorder = createRecorder(eventQueries, broadcasts); + recorder.onProgress('kilo_server', 'Starting Kilo…'); + + recorder.finalize({ status: 'failed', safeError: 'Environment preparation failed' }); + + const attempt = readAttempt(eventQueries, 'attempt-1'); + expect(attempt.status).toBe('failed'); + expect(attempt.safeError).toBe('Environment preparation failed'); + }); + + it('finalize is a no-op when no preparation progress was observed', () => { + const eventQueries = createMemoryEventQueries(); + const broadcasts: StoredEvent[] = []; + const recorder = createRecorder(eventQueries, broadcasts); + + recorder.finalize({ status: 'failed', safeError: 'Environment preparation failed' }); + + expect(broadcasts).toEqual([]); + expect(eventQueries.findByEntityPrefix('preparation/attempt/')).toEqual([]); + }); + + it('finalize settles an attempt the wrapper continued but never terminated', () => { + const eventQueries = createMemoryEventQueries(); + const broadcasts: StoredEvent[] = []; + const recorder = createRecorder(eventQueries, broadcasts); + recorder.onProgress('sandbox_provision', 'Provisioning sandbox…'); + + // The wrapper joins the same attempt with epoch-scale revisions and then + // loses its terminal event. + const wrapperRevision = 1_750_000_000_000; + materializePreparationEvent(eventQueries, storedEvent(5000), { + version: 2, + attemptId: 'attempt-1', + triggerMessageId: 'msg-1', + revision: wrapperRevision, + timestamp: 5000, + step: 'cloning', + message: 'Cloning repository', + action: 'step_started', + stepId: 'phase:cloning', + kind: 'phase', + label: 'cloning', + }); + + recorder.finalize({ status: 'completed' }); + + const attempt = readAttempt(eventQueries, 'attempt-1'); + expect(attempt.status).toBe('completed'); + expect(attempt.revision).toBeGreaterThan(wrapperRevision); + expect(attempt.startedAt).toBe(1000); + expect(readStep(eventQueries, 'attempt-1', 'phase:cloning').status).toBe('completed'); + }); +}); diff --git a/services/cloud-agent-next/src/session/preparation-progress.ts b/services/cloud-agent-next/src/session/preparation-progress.ts new file mode 100644 index 0000000000..e905df3408 --- /dev/null +++ b/services/cloud-agent-next/src/session/preparation-progress.ts @@ -0,0 +1,107 @@ +import type { PreparingEventDataV2, PreparingStep } from '../shared/protocol.js'; +import type { EventQueries } from './queries/index.js'; +import type { StoredEvent } from '../websocket/types.js'; +import type { EventId } from '../types/ids.js'; +import { + finalizePreparationAttempt, + materializePreparationEvent, + readPreparationAttempt, + type PreparationOutcome, +} from './preparation-history.js'; + +/** + * Records the worker-side portion of a preparation attempt: the progress the + * DO observes before the wrapper is up (sandbox provisioning, disk checks, + * backup restores) and the guaranteed terminal transition once delivery + * settles. The wrapper later joins the same attempt (its ready request + * carries the same attemptId) and continues it with bootstrap steps. + */ +export type PreparationProgressRecorder = { + readonly attemptId: string; + /** Translate a legacy (step, message) progress callback into v2 events. */ + onProgress(step: string, message: string): void; + /** + * Drive the attempt to a terminal state if it is still running. A no-op + * when no preparation happened or the wrapper already finished the attempt. + */ + finalize(outcome: PreparationOutcome): void; +}; + +export function createPreparationProgressRecorder(options: { + attemptId: string; + triggerMessageId: string; + sessionId: string; + eventQueries: EventQueries; + broadcast: (event: StoredEvent) => void; + now?: () => number; +}): PreparationProgressRecorder { + const { attemptId, triggerMessageId, sessionId, eventQueries, broadcast } = options; + const now = options.now ?? Date.now; + let activeStep: { id: string; key: PreparingStep } | undefined; + + function emit( + step: PreparingStep, + message: string, + action: + | { action: 'attempt_started' } + | { action: 'step_started'; stepId: string; kind: 'phase'; label: string } + | { action: 'step_progress'; stepId: string; detail: string } + | { action: 'step_completed'; stepId: string } + ): void { + // Revisions ride on the materialized snapshot so they stay monotonic no + // matter who (DO or wrapper) produced the previous event for this attempt. + const revision = (readPreparationAttempt(eventQueries, attemptId)?.revision ?? 0) + 1; + const data: PreparingEventDataV2 = { + version: 2, + attemptId, + triggerMessageId, + revision, + timestamp: now(), + step, + message, + ...action, + }; + const stored: StoredEvent = { + id: 0 as EventId, + execution_id: '', + session_id: sessionId, + stream_event_type: 'preparing', + payload: JSON.stringify(data), + timestamp: data.timestamp, + }; + if (materializePreparationEvent(eventQueries, stored, data)) broadcast(stored); + } + + function onProgress(step: string, message: string): void { + const key = step as PreparingStep; + if (!readPreparationAttempt(eventQueries, attemptId)) { + emit('workspace_setup', 'Preparing environment', { action: 'attempt_started' }); + } + const stepId = `phase:${key}`; + if (activeStep?.id !== stepId) { + if (activeStep) { + emit(activeStep.key, message, { action: 'step_completed', stepId: activeStep.id }); + } + emit(key, message, { + action: 'step_started', + stepId, + kind: 'phase', + label: key.replaceAll('_', ' '), + }); + activeStep = { id: stepId, key }; + } + emit(key, message, { action: 'step_progress', stepId, detail: message }); + } + + function finalize(outcome: PreparationOutcome): void { + activeStep = undefined; + for (const event of finalizePreparationAttempt(eventQueries, attemptId, { + ...outcome, + timestamp: now(), + })) { + broadcast(event); + } + } + + return { attemptId, onProgress, finalize }; +} diff --git a/services/cloud-agent-next/src/session/preparation-test-helpers.ts b/services/cloud-agent-next/src/session/preparation-test-helpers.ts new file mode 100644 index 0000000000..21536a2b00 --- /dev/null +++ b/services/cloud-agent-next/src/session/preparation-test-helpers.ts @@ -0,0 +1,97 @@ +import { materializePreparationEvent } from './preparation-history.js'; +import type { PreparationAttempt, PreparationStepSnapshot } from '../shared/protocol.js'; +import type { EventQueries } from './queries/index.js'; +import type { StoredEvent } from '../websocket/types.js'; + +/** Entity-keyed in-memory stand-in for the preparation slice of EventQueries. */ +export function createMemoryEventQueries(): EventQueries { + const rows = new Map(); + let nextId = 1; + return { + upsert: (params: { + executionId: string; + sessionId: string; + streamEventType: string; + payload: string; + timestamp: number; + entityId: string; + }) => { + const existing = rows.get(params.entityId); + const id = existing?.id ?? nextId++; + rows.set(params.entityId, { + id, + execution_id: params.executionId, + session_id: params.sessionId, + stream_event_type: params.streamEventType, + payload: params.payload, + timestamp: params.timestamp, + } as StoredEvent); + return id; + }, + findByEntityId: (entityId: string) => rows.get(entityId) ?? null, + findByEntityPrefix: (prefix: string) => + [...rows.entries()] + .filter(([entityId]) => entityId.startsWith(prefix)) + .map(([, row]) => row) + .sort((a, b) => a.timestamp - b.timestamp || a.id - b.id), + } as unknown as EventQueries; +} + +export function storedEvent( + timestamp: number +): Pick { + return { execution_id: 'exec-1', session_id: 'sess-1', timestamp }; +} + +/** Materialize a running attempt with one running kilo_server step. */ +export function seedRunningAttempt( + eventQueries: EventQueries, + options: { attemptId?: string; startedAt?: number } = {} +): { attemptId: string; lastEventAt: number } { + const attemptId = options.attemptId ?? 'attempt-1'; + const startedAt = options.startedAt ?? 1000; + materializePreparationEvent(eventQueries, storedEvent(startedAt), { + version: 2, + attemptId, + triggerMessageId: 'msg-1', + revision: 1, + timestamp: startedAt, + step: 'workspace_setup', + message: 'Preparing environment', + action: 'attempt_started', + }); + const lastEventAt = startedAt + 5000; + materializePreparationEvent(eventQueries, storedEvent(lastEventAt), { + version: 2, + attemptId, + triggerMessageId: 'msg-1', + revision: 2, + timestamp: lastEventAt, + step: 'kilo_server', + message: 'Starting Kilo', + action: 'step_started', + stepId: 'phase:kilo_server', + kind: 'phase', + label: 'kilo server', + }); + return { attemptId, lastEventAt }; +} + +export function readAttempt( + eventQueries: EventQueries, + attemptId: string +): Omit { + const row = eventQueries.findByEntityId(`preparation/attempt/${attemptId}`); + if (!row) throw new Error('Expected attempt snapshot row'); + return (JSON.parse(row.payload) as { attempt: Omit }).attempt; +} + +export function readStep( + eventQueries: EventQueries, + attemptId: string, + stepId: string +): PreparationStepSnapshot { + const row = eventQueries.findByEntityId(`preparation/attempt/${attemptId}/step/${stepId}`); + if (!row) throw new Error('Expected step snapshot row'); + return (JSON.parse(row.payload) as { stepSnapshot: PreparationStepSnapshot }).stepSnapshot; +} diff --git a/services/cloud-agent-next/src/session/queries/events.ts b/services/cloud-agent-next/src/session/queries/events.ts index 4ec65dc357..92394dddb1 100644 --- a/services/cloud-agent-next/src/session/queries/events.ts +++ b/services/cloud-agent-next/src/session/queries/events.ts @@ -1,4 +1,4 @@ -import { count, max, eq, and, gt, gte, lte, lt, inArray, asc } from 'drizzle-orm'; +import { count, max, eq, and, gt, gte, lte, lt, inArray, asc, like } from 'drizzle-orm'; import type { DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; import * as z from 'zod'; import type { StoredEvent } from '../../websocket/types.js'; @@ -290,6 +290,38 @@ export function createEventQueries(db: DrizzleSqliteDODatabase, rawSql: SqlStora return query.all() satisfies StoredEvent[]; }, + findByEntityId(entityId: string): StoredEvent | null { + const row = db + .select({ + id: events.id, + execution_id: events.execution_id, + session_id: events.session_id, + stream_event_type: events.stream_event_type, + payload: events.payload, + timestamp: events.timestamp, + }) + .from(events) + .where(eq(events.entity_id, entityId)) + .get(); + return row ?? null; + }, + + findByEntityPrefix(prefix: string): StoredEvent[] { + return db + .select({ + id: events.id, + execution_id: events.execution_id, + session_id: events.session_id, + stream_event_type: events.stream_event_type, + payload: events.payload, + timestamp: events.timestamp, + }) + .from(events) + .where(like(events.entity_id, `${prefix}%`)) + .orderBy(asc(events.timestamp), asc(events.id)) + .all() satisfies StoredEvent[]; + }, + getLatestAssistantMessage( sessionId: string, kiloSessionId: string diff --git a/services/cloud-agent-next/src/session/session-message-queue.test.ts b/services/cloud-agent-next/src/session/session-message-queue.test.ts index f7b384d674..b0b071320b 100644 --- a/services/cloud-agent-next/src/session/session-message-queue.test.ts +++ b/services/cloud-agent-next/src/session/session-message-queue.test.ts @@ -1556,6 +1556,72 @@ describe('SessionMessageQueue', () => { }); }); + it('terminalizes a non-retryable setup command failure on its first attempt', async () => { + const error = 'Setup command failed: sh -lc "[REDACTED]" (exit code 1)'; + const harness = createQueueHarness({ + deliver: async () => + Promise.reject( + ExecutionError.workspaceSetupFailed(error, undefined, { + subtype: 'setup_command_failed', + safeFailureMessage: `${error}\noutput: authentication failed for [REDACTED]`, + retryable: false, + }) + ), + }); + await harness.queue.admitSubmittedMessage({ + userId: 'user_test' as UserId, + turn: { type: 'prompt', id: FIRST_MESSAGE_ID, prompt: 'prepare the workspace' }, + }); + + await harness.queue.drainNextPendingMessage(); + + expect(harness.deliver).toHaveBeenCalledOnce(); + expect(await listPendingSessionMessages(harness.storage)).toHaveLength(0); + expect(harness.terminalizations).toHaveLength(1); + expect(harness.terminalizations[0]?.params).toMatchObject({ + kind: 'failed', + error, + attempts: 1, + failureStage: 'pre_dispatch', + failureCode: 'workspace_setup_failed', + failureSubtype: 'setup_command_failed', + safeFailureMessage: `${error}\noutput: authentication failed for [REDACTED]`, + }); + }); + + it('keeps a setup command timeout retryable after its first attempt', async () => { + const error = 'Setup command timed out after 300 seconds'; + const harness = createQueueHarness({ + deliver: async () => + Promise.reject( + ExecutionError.workspaceSetupFailed(error, undefined, { + subtype: 'setup_command_timeout', + safeFailureMessage: error, + retryable: true, + }) + ), + }); + await harness.queue.admitSubmittedMessage({ + userId: 'user_test' as UserId, + turn: { type: 'prompt', id: FIRST_MESSAGE_ID, prompt: 'prepare the workspace' }, + }); + + await harness.queue.drainNextPendingMessage(); + + expect(harness.deliver).toHaveBeenCalledOnce(); + const [pending] = await listPendingSessionMessages(harness.storage); + expect(pending).toMatchObject({ + flushAttempts: 1, + lastFlushError: error, + lastFlushFailureCode: 'WORKSPACE_SETUP_FAILED', + lastFlushFailureSubtype: 'setup_command_timeout', + safeFailureMessage: error, + }); + expect(pending?.nextFlushAttemptAt).toBeDefined(); + expect(pending?.deliveryDisposition).toBeUndefined(); + expect(harness.terminalizations).toHaveLength(0); + }); + it('preserves a thrown workspace setup failure through retry exhaustion', async () => { const error = 'Git clone failed: No space left on device'; const harness = createQueueHarness({ diff --git a/services/cloud-agent-next/src/shared/protocol.ts b/services/cloud-agent-next/src/shared/protocol.ts index 0d28d29aae..d049acb544 100644 --- a/services/cloud-agent-next/src/shared/protocol.ts +++ b/services/cloud-agent-next/src/shared/protocol.ts @@ -131,21 +131,91 @@ export type PreparingStep = | 'setup_commands' | 'workspace_restore' | 'workspace_backup' + | 'sandbox_provision' + | 'sandbox_boot' | 'kilo_server' | 'kilo_session' | 'ready' | 'failed'; -/** - * Data included in 'preparing' events (workspace preparation progress). - */ -export type PreparingEventData = { +export type PreparationAttemptStatus = 'running' | 'completed' | 'failed'; +export type PreparationStepKind = 'phase' | 'setup_command'; +export type PreparationStepStatus = 'running' | 'completed' | 'failed'; + +export type PreparationStepSnapshot = { + id: string; + key: PreparingStep; + kind: PreparationStepKind; + label: string; + status: PreparationStepStatus; + startedAt: number; + completedAt?: number; + revision: number; + latestDetail?: string; + safeError?: string; + command?: string; + commandIndex?: number; + commandCount?: number; + outputTail?: string; + outputTruncated?: boolean; + exitCode?: number; +}; + +export type PreparationAttempt = { + id: string; + triggerMessageId: string; + status: PreparationAttemptStatus; + startedAt: number; + completedAt?: number; + safeError?: string; + revision: number; + steps: PreparationStepSnapshot[]; +}; + +type PreparingEventDataV2Base = { + version: 2; + attemptId: string; + triggerMessageId: string; + revision: number; + timestamp: number; step: PreparingStep; message: string; - /** Branch name, included in the 'ready' step after preparation completes. */ - branch?: string; }; +export type PreparingEventDataV2 = PreparingEventDataV2Base & + ( + | { action: 'attempt_started' } + | { + action: 'step_started'; + stepId: string; + kind: PreparationStepKind; + label: string; + command?: string; + commandIndex?: number; + commandCount?: number; + } + | { action: 'step_progress'; stepId: string; detail: string } + | { action: 'step_output'; stepId: string; output: string } + | { action: 'step_completed'; stepId: string; exitCode?: number } + | { action: 'step_failed'; stepId: string; safeError: string; exitCode?: number } + | { action: 'attempt_completed' } + | { action: 'attempt_failed'; safeError: string } + | { action: 'attempt_snapshot'; attempt: Omit } + | { action: 'step_snapshot'; stepSnapshot: PreparationStepSnapshot } + ); + +/** + * Data included in 'preparing' events (workspace preparation progress). + */ +export type PreparingEventData = + | { + step: PreparingStep; + message: string; + /** Branch name, included in the 'ready' step after preparation completes. */ + branch?: string; + } + | PreparingEventDataV2; + /** Cloud infrastructure status types. */ export type CloudStatusType = 'preparing' | 'ready' | 'finalizing' | 'error'; diff --git a/services/cloud-agent-next/src/shared/wrapper-bootstrap.ts b/services/cloud-agent-next/src/shared/wrapper-bootstrap.ts index ab6ac6a290..c049d3c7cf 100644 --- a/services/cloud-agent-next/src/shared/wrapper-bootstrap.ts +++ b/services/cloud-agent-next/src/shared/wrapper-bootstrap.ts @@ -129,6 +129,10 @@ export type WrapperSessionReadyRequest = { devcontainer?: WrapperBootstrapDevContainer; materialized: WrapperBootstrapMaterializedConfig; session: WrapperSessionBinding; + preparation?: { + attemptId: string; + triggerMessageId: string; + }; }; export type WrapperWorkspaceReady = { @@ -238,5 +242,12 @@ export function isWrapperSessionReadyRequest(value: unknown): value is WrapperSe if (typeof session.wrapperGeneration !== 'number') return false; if (!hasString(session, 'wrapperConnectionId')) return false; + const preparation = value.preparation; + if (preparation !== undefined) { + if (!isRecord(preparation)) return false; + if (!hasString(preparation, 'attemptId')) return false; + if (!hasString(preparation, 'triggerMessageId')) return false; + } + return true; } diff --git a/services/cloud-agent-next/src/websocket/ingest.ts b/services/cloud-agent-next/src/websocket/ingest.ts index ab4b564b40..65d953ba36 100644 --- a/services/cloud-agent-next/src/websocket/ingest.ts +++ b/services/cloud-agent-next/src/websocket/ingest.ts @@ -36,6 +36,10 @@ import type { WrapperSupervisor, WrapperTerminalEvent } from '../session/wrapper import type { TerminalizeParams } from '../session/session-message-state.js'; import { classifyAssistantFailureMessage } from '../session/safe-failure-projection.js'; import { parseModelNotFoundRuntimeDiagnostics } from '../shared/runtime-model-diagnostics.js'; +import { + cloudStatusForPreparingEvent, + materializePreparationEvent, +} from '../session/preparation-history.js'; // --------------------------------------------------------------------------- // Ingest Attachment @@ -679,21 +683,29 @@ export function createIngestHandler( broadcastFn(storedEvent); if (eventType === 'preparing') { - const preparingData = ingestEvent.data as { step?: string; message?: string } | undefined; - broadcastFn({ - id: 0 as EventId, - execution_id: eventSourceId, - session_id: sessionId, - stream_event_type: 'cloud.status', - payload: JSON.stringify({ - cloudStatus: { - type: 'preparing', - step: preparingData?.step, - message: preparingData?.message, - }, - } satisfies CloudStatusData), - timestamp, - }); + let applied = false; + try { + applied = materializePreparationEvent(eventQueries, storedEvent, ingestEvent.data); + } catch (error) { + logger + .withFields({ + sessionId, + wrapperRunId: attachment.wrapperRunId, + error: error instanceof Error ? error.message : String(error), + }) + .warn('Failed to materialize preparation history'); + } + const cloudStatus = cloudStatusForPreparingEvent(ingestEvent.data, applied); + if (cloudStatus) { + broadcastFn({ + id: 0 as EventId, + execution_id: eventSourceId, + session_id: sessionId, + stream_event_type: 'cloud.status', + payload: JSON.stringify({ cloudStatus } satisfies CloudStatusData), + timestamp, + }); + } } if (now - attachment.lastHeartbeatUpdate >= HEARTBEAT_DEBOUNCE_MS) { diff --git a/services/cloud-agent-next/src/websocket/stream.ts b/services/cloud-agent-next/src/websocket/stream.ts index 4f24b07437..f9d21fa610 100644 --- a/services/cloud-agent-next/src/websocket/stream.ts +++ b/services/cloud-agent-next/src/websocket/stream.ts @@ -101,6 +101,7 @@ export type StreamHandlerOptions = { */ getAvailableCommands?: () => Promise; deriveQueuedMessages?: () => Promise; + getPreparationSnapshots?: () => Promise; }; /** @@ -203,6 +204,19 @@ export function createStreamHandler( ); } + if (options?.getPreparationSnapshots) { + const snapshots = await options.getPreparationSnapshots(); + for (const snapshot of snapshots) { + if (!matchesFilters({ ...snapshot, id: 0 as EventId }, filters)) continue; + server.send( + JSON.stringify({ + ...formatStreamEvent({ ...snapshot, id: 0 as EventId }, sessionId), + eventId: 0, + }) + ); + } + } + // Send the cached slash-command catalog on connect only when the client // filters allow it. If the wrapper hasn't pushed yet the list is empty; // the wrapper's later push will arrive via the normal broadcast path. diff --git a/services/cloud-agent-next/test/unit/execution/orchestrator.test.ts b/services/cloud-agent-next/test/unit/execution/orchestrator.test.ts index 4525d2d8a8..18500be4d8 100644 --- a/services/cloud-agent-next/test/unit/execution/orchestrator.test.ts +++ b/services/cloud-agent-next/test/unit/execution/orchestrator.test.ts @@ -452,7 +452,7 @@ describe('ExecutionOrchestrator bootstrap execution', () => { await rejection; }); - it('reports Kilo startup progress when delivering to a warm workspace', async () => { + it('reports provisioning and sandbox boot progress when delivering to a warm workspace', async () => { const { sandbox } = createMockSandbox({ workspaceWarm: true }); const { ensureSessionReady, prompt } = stubWrapperBootstrap(); const orchestrator = createOrchestrator(sandbox); @@ -460,7 +460,10 @@ describe('ExecutionOrchestrator bootstrap execution', () => { await orchestrator.execute(createExecutionPlan(), { onProgress }); - expect(onProgress).toHaveBeenCalledExactlyOnceWith('kilo_server', 'Starting Kilo...'); + expect(onProgress.mock.calls).toEqual([ + ['sandbox_provision', 'Provisioning sandbox…'], + ['sandbox_boot', 'Starting sandbox agent...'], + ]); expect(ensureSessionReady).toHaveBeenCalledOnce(); expect(prompt).toHaveBeenCalledOnce(); }); diff --git a/services/cloud-agent-next/wrapper/src/connection.ts b/services/cloud-agent-next/wrapper/src/connection.ts index 7fbddc7bbf..d906b3e3d0 100644 --- a/services/cloud-agent-next/wrapper/src/connection.ts +++ b/services/cloud-agent-next/wrapper/src/connection.ts @@ -383,7 +383,7 @@ export async function openIngestProgressChannel( const close = () => { active = false; - state.setSendToIngestFn(null); + state.clearSendToIngestFn(sendProgressEvent); try { ws.close(); } catch { @@ -424,7 +424,7 @@ export async function openIngestProgressChannel( } if (active) { active = false; - state.setSendToIngestFn(null); + state.clearSendToIngestFn(sendProgressEvent); } }; diff --git a/services/cloud-agent-next/wrapper/src/main.ts b/services/cloud-agent-next/wrapper/src/main.ts index 39bc1d49ae..3fa4b8f6eb 100644 --- a/services/cloud-agent-next/wrapper/src/main.ts +++ b/services/cloud-agent-next/wrapper/src/main.ts @@ -16,7 +16,11 @@ */ import { createKilo } from '@kilocode/sdk'; -import { SESSION_ID_RE } from '../../src/shared/protocol.js'; +import { + SESSION_ID_RE, + type PreparingEventDataV2, + type PreparingStep, +} from '../../src/shared/protocol.js'; import { WRAPPER_VERSION } from '../../src/shared/wrapper-version.js'; import { WrapperState } from './state.js'; import { createWrapperKiloClient, type WrapperKiloClient } from './kilo-api.js'; @@ -38,6 +42,9 @@ import type { WrapperSessionReadyResponse, } from '../../src/shared/wrapper-bootstrap.js'; import { + type BootstrapProgress, + type BootstrapProgressEvent, + type BootstrapProgressStep, materializePromptAttachments, prepareWrapperBootstrapWorkspace, RestoredWorkspaceReconciliationError, @@ -586,6 +593,13 @@ async function main() { const readyStartedAt = Date.now(); let progressChannel: Awaited> | undefined; + // Revisions must strictly increase across every producer touching this + // attempt: the server emits early progress (small revisions counted up + // from the materialized snapshot) before the wrapper exists, and a + // readiness retry re-enters here with the same attemptId. Starting from + // the current epoch keeps each readySession pass above both. + let preparationRevision = Date.now(); + let emitPreparing: ((data: PreparingEventDataV2) => void) | undefined; logToFile( `session/ready received agentSessionId=${request.agentSessionId} kiloSessionId=${request.kiloSessionId} preferSnapshot=${request.workspace.preferSnapshot} workspacePath=${request.workspace.workspacePath} sessionHome=${request.workspace.sessionHome} branchName=${request.workspace.branchName} strictBranch=${request.workspace.strictBranch ?? false} repoKind=${request.repo?.kind ?? '(none)'} setupCommandCount=${request.materialized.setupCommands?.length ?? 0} runtimeSkillCount=${request.materialized.runtimeSkills?.length ?? 0} platform=${request.materialized.env.KILO_PLATFORM ?? process.env.KILO_PLATFORM ?? '(unset)'} stateConnected=${state.isConnected}` ); @@ -632,15 +646,144 @@ async function main() { logToFile( `session/ready bootstrap workspace starting kiloSessionId=${request.kiloSessionId}` ); - const workspaceBootstrap = prepareWrapperBootstrapWorkspace( - request, - (step, message) => { + let activeStepId: string | undefined; + const completedStepIds = new Set(); + emitPreparing = (data: PreparingEventDataV2): void => { + const event = { + ...data, + revision: ++preparationRevision, + timestamp: Date.now(), + } satisfies PreparingEventDataV2; + state.sendToIngest({ + streamEventType: 'preparing', + data: event, + timestamp: new Date(event.timestamp).toISOString(), + }); + }; + const emitBootstrapProgress: BootstrapProgress = ( + eventOrStep: BootstrapProgressEvent | BootstrapProgressStep, + legacyMessage?: string + ) => { + const event: BootstrapProgressEvent = + typeof eventOrStep === 'string' + ? { + type: 'progress', + step: eventOrStep, + stepId: `phase:${eventOrStep}`, + detail: legacyMessage ?? '', + } + : eventOrStep; + const message = + event.type === 'output' + ? event.output + : event.type === 'progress' + ? event.detail + : event.type === 'failed' + ? event.safeError + : event.type === 'started' + ? event.label + : 'Preparation complete'; + if (!request.preparation) { state.sendToIngest({ streamEventType: 'preparing', - data: { step, message }, + data: { step: event.step, message }, timestamp: new Date().toISOString(), }); - }, + return; + } + const base = { + version: 2 as const, + attemptId: request.preparation.attemptId, + triggerMessageId: request.preparation.triggerMessageId, + revision: ++preparationRevision, + timestamp: Date.now(), + step: event.step as PreparingStep, + message, + }; + if (event.type === 'started') { + if (activeStepId && !completedStepIds.has(activeStepId)) { + emitPreparing?.({ ...base, action: 'step_completed', stepId: activeStepId }); + completedStepIds.add(activeStepId); + } + activeStepId = event.stepId; + emitPreparing?.({ + ...base, + action: 'step_started', + stepId: event.stepId, + kind: event.kind, + label: event.label, + ...(event.command === undefined ? {} : { command: event.command }), + ...(event.commandIndex === undefined ? {} : { commandIndex: event.commandIndex }), + ...(event.commandCount === undefined ? {} : { commandCount: event.commandCount }), + }); + return; + } + if (event.type === 'progress') { + if (activeStepId !== event.stepId) { + if (activeStepId && !completedStepIds.has(activeStepId)) { + emitPreparing?.({ ...base, action: 'step_completed', stepId: activeStepId }); + completedStepIds.add(activeStepId); + } + activeStepId = event.stepId; + emitPreparing?.({ + ...base, + action: 'step_started', + stepId: event.stepId, + kind: 'phase', + label: event.step.replaceAll('_', ' '), + }); + } + emitPreparing?.({ + ...base, + action: 'step_progress', + stepId: event.stepId, + detail: event.detail, + }); + return; + } + if (event.type === 'output') { + emitPreparing?.({ + ...base, + action: 'step_output', + stepId: event.stepId, + output: event.output, + }); + return; + } + if (event.type === 'completed') { + completedStepIds.add(event.stepId); + emitPreparing?.({ + ...base, + action: 'step_completed', + stepId: event.stepId, + exitCode: event.exitCode, + }); + return; + } + completedStepIds.add(event.stepId); + emitPreparing?.({ + ...base, + action: 'step_failed', + stepId: event.stepId, + safeError: event.safeError, + exitCode: event.exitCode, + }); + }; + if (request.preparation) { + emitPreparing({ + version: 2, + attemptId: request.preparation.attemptId, + triggerMessageId: request.preparation.triggerMessageId, + revision: ++preparationRevision, + timestamp: Date.now(), + step: 'workspace_setup', + message: 'Preparing environment', + action: 'attempt_started', + }); + } + const workspaceBootstrap = prepareWrapperBootstrapWorkspace( + request, + emitBootstrapProgress, {}, workspaceBootstrapController.signal ); @@ -654,9 +797,6 @@ async function main() { `session/ready bootstrap workspace finished kiloSessionId=${request.kiloSessionId}` ); - progressChannel?.close(); - progressChannel = undefined; - if (isShuttingDown) return wrapperFinalizingResponse(); const runtimeStartup = startKiloRuntime( request.workspace.workspacePath, @@ -675,6 +815,32 @@ async function main() { `session/ready complete kiloSessionId=${request.kiloSessionId} elapsedMs=${Date.now() - readyStartedAt}` ); + if (request.preparation) { + if (activeStepId && !completedStepIds.has(activeStepId)) { + emitPreparing({ + version: 2, + attemptId: request.preparation.attemptId, + triggerMessageId: request.preparation.triggerMessageId, + revision: ++preparationRevision, + timestamp: Date.now(), + step: 'kilo_server', + message: 'Starting Kilo', + action: 'step_completed', + stepId: activeStepId, + }); + } + emitPreparing({ + version: 2, + attemptId: request.preparation.attemptId, + triggerMessageId: request.preparation.triggerMessageId, + revision: ++preparationRevision, + timestamp: Date.now(), + step: 'ready', + message: 'Preparation complete', + action: 'attempt_completed', + }); + } + return { status: 'ready', kiloSessionId: request.kiloSessionId, @@ -687,6 +853,25 @@ async function main() { }, }; } catch (error) { + if (request.preparation) { + const safeError = + error instanceof WrapperBootstrapError + ? error.message + : error instanceof RestoredWorkspaceReconciliationError + ? 'Workspace reconciliation failed' + : 'Environment preparation failed'; + emitPreparing?.({ + version: 2, + attemptId: request.preparation.attemptId, + triggerMessageId: request.preparation.triggerMessageId, + revision: ++preparationRevision, + timestamp: Date.now(), + step: 'failed', + message: safeError, + action: 'attempt_failed', + safeError, + }); + } if (isShuttingDown) { logToFile( `session/ready aborted by shutdown kiloSessionId=${request.kiloSessionId} elapsedMs=${Date.now() - readyStartedAt}` diff --git a/services/cloud-agent-next/wrapper/src/redact-output.test.ts b/services/cloud-agent-next/wrapper/src/redact-output.test.ts new file mode 100644 index 0000000000..f19789bf6a --- /dev/null +++ b/services/cloud-agent-next/wrapper/src/redact-output.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'bun:test'; +import { redactSecrets } from './redact-output'; + +describe('redactSecrets', () => { + it('redacts bearer tokens in Authorization headers', () => { + expect(redactSecrets('Authorization: Bearer ghp_abc123def456')).toBe( + 'Authorization: Bearer [REDACTED]' + ); + }); + + it('redacts basic auth in Authorization headers', () => { + expect(redactSecrets('Authorization: Basic dXNlcjpwYXNz')).toBe( + 'Authorization: Basic [REDACTED]' + ); + }); + + it('redacts URL-embedded credentials', () => { + expect(redactSecrets('https://user:password@example.com/repo.git')).toBe( + 'https://[REDACTED]@example.com/repo.git' + ); + expect( + redactSecrets('Cloning into https://x-access-token:ghs_token@github.com/owner/repo') + ).toBe('Cloning into https://[REDACTED]@github.com/owner/repo'); + expect(redactSecrets('https://single-token@example.com/repo.git')).toBe( + 'https://[REDACTED]@example.com/repo.git' + ); + }); + + it('redacts Cookie headers', () => { + expect(redactSecrets('Cookie: session=abc123; csrf=xyz')).toBe('Cookie: [REDACTED]'); + }); + + it('redacts KEY=VALUE where key contains secret-like names', () => { + expect(redactSecrets('SECRET_VALUE=env-secret')).toBe('SECRET_VALUE=[REDACTED]'); + expect(redactSecrets('GITHUB_TOKEN=ghp_abc123')).toBe('GITHUB_TOKEN=[REDACTED]'); + expect(redactSecrets('export DATABASE_PASSWORD=hunter2')).toBe( + 'export DATABASE_PASSWORD=[REDACTED]' + ); + expect(redactSecrets('API_KEY=sk-abc123')).toBe('API_KEY=[REDACTED]'); + expect(redactSecrets('DATABASE_PASSWORD="secret with spaces"')).toBe( + 'DATABASE_PASSWORD=[REDACTED]' + ); + }); + + it('does not redact non-secret KEY=VALUE pairs', () => { + expect(redactSecrets('PATH=/usr/local/bin')).toBe('PATH=/usr/local/bin'); + expect(redactSecrets('NODE_ENV=production')).toBe('NODE_ENV=production'); + expect(redactSecrets('HOME=/home/user')).toBe('HOME=/home/user'); + }); + + it('redacts CLI flags with secret values', () => { + expect(redactSecrets('private-tool --token argv-secret')).toBe( + 'private-tool --token [REDACTED]' + ); + expect(redactSecrets('curl --password mypass https://example.com')).toBe( + 'curl --password [REDACTED] https://example.com' + ); + expect(redactSecrets('tool --api-key=sk_abc123')).toBe('tool --api-key=[REDACTED]'); + expect(redactSecrets("tool --github-token 'secret with spaces'")).toBe( + 'tool --github-token [REDACTED]' + ); + }); + + it('redacts multiple secrets in multi-line output', () => { + const input = [ + 'bare-unlabeled-token', + 'https://user:url-secret@example.com/repo.git', + 'Authorization: Bearer bearer-secret', + 'Cookie: session=cookie-secret', + 'SECRET_VALUE=env-secret', + ].join('\n'); + + const result = redactSecrets(input); + expect(result).not.toContain('url-secret'); + expect(result).not.toContain('bearer-secret'); + expect(result).not.toContain('cookie-secret'); + expect(result).not.toContain('env-secret'); + expect(result).toContain('https://[REDACTED]@example.com/repo.git'); + expect(result).toContain('Authorization: Bearer [REDACTED]'); + expect(result).toContain('Cookie: [REDACTED]'); + expect(result).toContain('SECRET_VALUE=[REDACTED]'); + }); + + it('leaves non-secret content unchanged', () => { + expect(redactSecrets('npm install')).toBe('npm install'); + expect(redactSecrets('added 42 packages in 3s')).toBe('added 42 packages in 3s'); + expect(redactSecrets('Error: ENOENT: no such file or directory')).toBe( + 'Error: ENOENT: no such file or directory' + ); + }); +}); diff --git a/services/cloud-agent-next/wrapper/src/redact-output.ts b/services/cloud-agent-next/wrapper/src/redact-output.ts new file mode 100644 index 0000000000..f45d69ffd0 --- /dev/null +++ b/services/cloud-agent-next/wrapper/src/redact-output.ts @@ -0,0 +1,38 @@ +const REDACTED = '[REDACTED]'; + +const SECRET_PATTERNS: { pattern: RegExp; replacement: string }[] = [ + { + pattern: /(Authorization\s*:\s*Bearer\s+)(\S+)/gi, + replacement: `$1${REDACTED}`, + }, + { + pattern: /(Authorization\s*:\s*Basic\s+)(\S+)/gi, + replacement: `$1${REDACTED}`, + }, + { + pattern: /(https?:\/\/)[^\s/@]+@/gi, + replacement: `$1${REDACTED}@`, + }, + { + pattern: /((?:Set-)?Cookie\s*:\s*)[^\r\n]+/gi, + replacement: `$1${REDACTED}`, + }, + { + pattern: + /\b([A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSPHRASE|CREDENTIAL|API_KEY|PRIVATE_KEY|ACCESS_KEY)[A-Za-z0-9_]*)\s*=\s*(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s\r\n]+)/gi, + replacement: `$1=${REDACTED}`, + }, + { + pattern: + /(--[A-Za-z0-9-]*(?:token|password|secret|key|apikey|api-key|passphrase|credential)[A-Za-z0-9-]*(?:\s+|=))(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s\r\n]+)/gi, + replacement: `$1${REDACTED}`, + }, +]; + +export function redactSecrets(text: string): string { + let result = text; + for (const { pattern, replacement } of SECRET_PATTERNS) { + result = result.replace(pattern, replacement); + } + return result; +} diff --git a/services/cloud-agent-next/wrapper/src/session-bootstrap.test.ts b/services/cloud-agent-next/wrapper/src/session-bootstrap.test.ts index 0fa97b1d53..8dd64e7cb9 100644 --- a/services/cloud-agent-next/wrapper/src/session-bootstrap.test.ts +++ b/services/cloud-agent-next/wrapper/src/session-bootstrap.test.ts @@ -368,10 +368,11 @@ describe('prepareWrapperBootstrapWorkspace', () => { expect(fs.existsSync(request.workspace.sessionHome)).toBe(false); }); - it('uses a lenient inactivity watchdog and generic progress for setup commands', async () => { + it('uses pipes and forwards sanitized, redacted setup command output', async () => { const request = makeRequest(tmpDir); const progress = mock(() => {}); let setupOptions: Parameters>[2]; + let setupInvocation: string[] = []; let markerExistedDuringSetup = true; await prepareWrapperBootstrapWorkspace(request, progress, { @@ -385,12 +386,15 @@ describe('prepareWrapperBootstrapWorkspace', () => { opts?.onOutput?.('stderr', 'Updating files: 100% (1/1)\n'); return { stdout: '', stderr: '', exitCode: 0 }; }, - runProcess: async (_command, _args, opts) => { + runProcess: async (command, args, opts) => { + setupInvocation = [command, ...args]; setupOptions = opts; markerExistedDuringSetup = fs.existsSync( path.join(request.workspace.workspacePath, '.git', 'kilo-bootstrap-complete') ); - opts?.onOutput?.('stdout', 'secret setup output'); + opts?.onOutput?.('stdout', '\u001b[32mfirst stdout\u001b[0m\nspinner one\r'); + opts?.onOutput?.('stdout', 'spinner two\rdone stdout\npartial stdout'); + opts?.onOutput?.('stderr', 'Authorization: Bearer progress-token\nmeaningful stderr\r\n'); return { stdout: '', stderr: '', exitCode: 0 }; }, restoreSession: async () => ({ @@ -403,15 +407,56 @@ describe('prepareWrapperBootstrapWorkspace', () => { expect(setupOptions?.inactivityTimeoutMs).toBe(240_000); expect(setupOptions?.hardTimeoutMs).toBe(300_000); + expect(setupInvocation).toEqual(['sh', '-lc', 'pnpm install']); expect(markerExistedDuringSetup).toBe(false); expect( fs.existsSync(path.join(request.workspace.workspacePath, '.git', 'kilo-bootstrap-complete')) ).toBe(true); - expect(progress).toHaveBeenCalledWith( - 'setup_commands', - 'Setup command 1 of 1 is still running...' - ); - expect(progress.mock.calls.flat().join(' ')).not.toContain('secret setup output'); + expect(progress).toHaveBeenCalledWith({ + type: 'started', + step: 'setup_commands', + stepId: 'setup_command:0', + kind: 'setup_command', + label: 'Setup command 1', + command: 'pnpm install', + commandIndex: 0, + commandCount: 1, + }); + expect(progress).toHaveBeenCalledWith({ + type: 'output', + step: 'setup_commands', + stepId: 'setup_command:0', + output: 'first stdout\n', + }); + expect(progress).toHaveBeenCalledWith({ + type: 'output', + step: 'setup_commands', + stepId: 'setup_command:0', + output: 'done stdout\n', + }); + expect(progress).toHaveBeenCalledWith({ + type: 'output', + step: 'setup_commands', + stepId: 'setup_command:0', + output: 'partial stdout\n', + }); + expect(progress).toHaveBeenCalledWith({ + type: 'output', + step: 'setup_commands', + stepId: 'setup_command:0', + output: 'Authorization: Bearer [REDACTED]\nmeaningful stderr\n', + }); + expect(progress).toHaveBeenCalledWith({ + type: 'completed', + step: 'setup_commands', + stepId: 'setup_command:0', + exitCode: 0, + }); + const progressText = progress.mock.calls.flat().join('\n'); + expect(progressText).not.toContain('spinner one'); + expect(progressText).not.toContain('spinner two'); + expect(progressText).not.toContain('progress-token'); + expect(progressText).not.toContain('\u001b'); }); it('fetches and checks out strict GitHub pull refs directly', async () => { @@ -489,7 +534,7 @@ describe('prepareWrapperBootstrapWorkspace', () => { expect(gitCalls.some(args => args[0] === 'rev-parse')).toBe(false); }); - it('keeps cold snapshot resumes alive when a setup command fails', async () => { + it('fails cold snapshot resumes when a setup command exits nonzero', async () => { const request = makeRequest(tmpDir); request.workspace.preferSnapshot = true; const deps: WrapperBootstrapDeps = { @@ -511,10 +556,10 @@ describe('prepareWrapperBootstrapWorkspace', () => { }), }; - const result = await prepareWrapperBootstrapWorkspace(request, undefined, deps); - - expect(result).toEqual({ - workspaceWasWarm: false, + expect(prepareWrapperBootstrapWorkspace(request, undefined, deps)).rejects.toMatchObject({ + code: 'WORKSPACE_SETUP_FAILED', + subtype: 'setup_command_failed', + retryable: false, }); }); @@ -697,7 +742,7 @@ describe('prepareWrapperBootstrapWorkspace', () => { }); }); - it('still fails fresh cold bootstraps without exposing setup command or output', async () => { + it('exposes redacted setup command and stderr on failure but redacts secrets', async () => { const request = makeRequest(tmpDir); request.materialized.setupCommands = ['private-tool --token argv-secret']; const deps: WrapperBootstrapDeps = { @@ -745,18 +790,23 @@ describe('prepareWrapperBootstrapWorkspace', () => { expect(setupError).toMatchObject({ code: 'WORKSPACE_SETUP_FAILED', subtype: 'setup_command_failed', - retryable: true, + retryable: false, }); expect(setupError.message).toBe('Setup command 1 failed'); - expect(setupError).toMatchObject({ - detail: 'termination nonzero exit, exit code 1, output truncated', - }); + const detail = (setupError as { detail?: string }).detail ?? ''; + expect(detail).toContain('command: private-tool --token [REDACTED]'); + expect(detail).toContain('exit code 1'); + expect(detail).toContain('output truncated'); + expect(detail).toContain('output:'); + expect(detail).toContain('https://[REDACTED]@example.com/repo.git'); + expect(detail).toContain('Authorization: Bearer [REDACTED]'); + expect(detail).toContain('Cookie: [REDACTED]'); + expect(detail).toContain('SECRET_VALUE=[REDACTED]'); + expect(detail).toContain('private-file-content'); + expect(detail).toContain('bare-unlabeled-token'); const projectedError = JSON.stringify(setupError); for (const sensitiveValue of [ - 'private-tool', 'argv-secret', - 'private-file-content', - 'bare-unlabeled-token', 'url-secret', 'bearer-secret', 'cookie-secret', @@ -795,6 +845,7 @@ describe('prepareWrapperBootstrapWorkspace', () => { }) ).rejects.toMatchObject({ subtype: 'setup_command_timeout', + retryable: true, message: expect.not.stringContaining('setup-secret'), detail: expect.not.stringContaining('setup-secret'), }); diff --git a/services/cloud-agent-next/wrapper/src/session-bootstrap.ts b/services/cloud-agent-next/wrapper/src/session-bootstrap.ts index a51bac5708..4391ddb01c 100644 --- a/services/cloud-agent-next/wrapper/src/session-bootstrap.ts +++ b/services/cloud-agent-next/wrapper/src/session-bootstrap.ts @@ -6,6 +6,7 @@ import { type WrapperPromptPart, type WrapperSessionReadyRequest, } from '../../src/shared/wrapper-bootstrap.js'; +import type { PreparationStepKind } from '../../src/shared/protocol.js'; import { buildCloudAgentRules } from '../../src/shared/cloud-agent-rules.js'; import { createSafeProcessDiagnostic, @@ -17,7 +18,9 @@ import { type ProcessOptions, type ProcessOutputStream, } from './utils.js'; +import { redactSecrets } from './redact-output.js'; import { restoreSession } from './restore-session.js'; +import { stripAnsi } from './event-parser.js'; import { WrapperBootstrapError, workspaceBootstrapError } from './bootstrap-error.js'; const LONG_COMMAND_INACTIVITY_TIMEOUT_MS = 120_000; @@ -30,9 +33,74 @@ const WORKSPACE_PREPARATION_TIMEOUT_MS = 8 * 60_000; const WORKSPACE_CLEANUP_TIMEOUT_MS = 60_000; const SHORT_GIT_COMMAND_TIMEOUT_MS = 120_000; const PROGRESS_UPDATE_INTERVAL_MS = 5_000; +const SETUP_COMMAND_ERROR_OUTPUT_MAX_BYTES = 4_096; +const SETUP_COMMAND_DIAGNOSTIC_MAX_BYTES = 1_024; const GIT_BOOTSTRAP_MARKER = 'kilo-bootstrap-complete'; const MAX_ATTACHMENT_BYTES = 5_242_880; +function cleanTerminalOutput(text: string): string { + return stripAnsi(text) + .replace(/\r\n/g, '\n') + .split('\n') + .map(line => line.split('\r').at(-1) ?? '') + .map(line => + Array.from(line) + .filter(character => { + const codePoint = character.codePointAt(0) ?? 0; + return ( + codePoint === 9 || + (codePoint >= 32 && codePoint !== 127 && (codePoint < 128 || codePoint > 159)) + ); + }) + .join('') + ) + .join('\n'); +} + +function boundedUtf8Tail(text: string, maxBytes: number): string { + const bytes = Buffer.from(text); + if (bytes.length <= maxBytes) return text; + return bytes + .subarray(bytes.length - maxBytes) + .toString('utf8') + .replace(/^\uFFFD/, ''); +} + +function createSetupOutputReporter( + progress: BootstrapProgress | undefined, + stepId: string +): { + onOutput: (stream: ProcessOutputStream, output: string) => void; + flush: () => void; +} { + const buffers: Record = { stdout: '', stderr: '' }; + + const report = (text: string): void => { + const cleaned = cleanTerminalOutput(text).trim(); + if (cleaned) + progress?.({ + type: 'output', + step: 'setup_commands', + stepId, + output: `${redactSecrets(cleaned)}\n`, + }); + }; + + return { + onOutput(stream, output) { + const lines = (buffers[stream] + output).split('\n'); + buffers[stream] = lines.pop() ?? ''; + report(lines.map(line => (line.endsWith('\r') ? line.slice(0, -1) : line)).join('\n')); + }, + flush() { + report(buffers.stdout); + report(buffers.stderr); + buffers.stdout = ''; + buffers.stderr = ''; + }, + }; +} + export type BootstrapProgressStep = | 'disk_check' | 'workspace_setup' @@ -43,7 +111,32 @@ export type BootstrapProgressStep = | 'attachments' | 'kilo_server'; -export type BootstrapProgress = (step: BootstrapProgressStep, message: string) => void; +export type BootstrapProgressEvent = + | { + type: 'started'; + step: BootstrapProgressStep; + stepId: string; + kind: PreparationStepKind; + label: string; + command?: string; + commandIndex?: number; + commandCount?: number; + } + | { type: 'progress'; step: BootstrapProgressStep; stepId: string; detail: string } + | { type: 'output'; step: 'setup_commands'; stepId: string; output: string } + | { type: 'completed'; step: BootstrapProgressStep; stepId: string; exitCode?: number } + | { + type: 'failed'; + step: BootstrapProgressStep; + stepId: string; + safeError: string; + exitCode?: number; + }; + +export type BootstrapProgress = { + (event: BootstrapProgressEvent): void; + (step: BootstrapProgressStep, message: string): void; +}; export type WrapperBootstrapResult = { workspaceWasWarm: boolean; @@ -643,47 +736,77 @@ async function reconcileRestoredWorkspace( async function runSetupCommands( request: WrapperSessionReadyRequest, run: ProcessRunner, - failFast: boolean, progress: BootstrapProgress | undefined ): Promise { const setupCommands = request.materialized.setupCommands ?? []; logToFile( - `bootstrap setup commands starting kiloSessionId=${request.kiloSessionId} count=${setupCommands.length} failFast=${failFast} workspacePath=${request.workspace.workspacePath}` + `bootstrap setup commands starting kiloSessionId=${request.kiloSessionId} count=${setupCommands.length} workspacePath=${request.workspace.workspacePath}` ); for (const [index, command] of setupCommands.entries()) { - let lastProgressAt = 0; const startedAt = Date.now(); + const stepId = `setup_command:${index}`; + const safeCommand = redactSecrets(command); + const outputReporter = createSetupOutputReporter(progress, stepId); + progress?.({ + type: 'started', + step: 'setup_commands', + stepId, + kind: 'setup_command', + label: `Setup command ${index + 1}`, + command: safeCommand, + commandIndex: index, + commandCount: setupCommands.length, + }); const result = await run('sh', ['-lc', command], { cwd: request.workspace.workspacePath, inactivityTimeoutMs: SETUP_COMMAND_INACTIVITY_TIMEOUT_MS, hardTimeoutMs: LONG_COMMAND_HARD_TIMEOUT_MS, - onOutput: () => { - const now = Date.now(); - if (lastProgressAt !== 0 && now - lastProgressAt < PROGRESS_UPDATE_INTERVAL_MS) return; - lastProgressAt = now; - progress?.( - 'setup_commands', - `Setup command ${index + 1} of ${setupCommands.length} is still running...` - ); - }, + onOutput: outputReporter.onOutput, }); + outputReporter.flush(); logToFile( `bootstrap setup command finished kiloSessionId=${request.kiloSessionId} index=${index + 1} count=${setupCommands.length} elapsedMs=${Date.now() - startedAt} exitCode=${result.exitCode} terminationReason=${result.terminationReason ?? '(none)'}` ); - if (result.exitCode !== 0 && failFast) { + if (result.exitCode !== 0) { const timedOut = isTimeoutTermination(result); + const safeError = `Setup command ${index + 1} ${timedOut ? 'timed out' : 'failed'}`; + progress?.({ + type: 'failed', + step: 'setup_commands', + stepId, + safeError, + exitCode: result.exitCode, + }); throw workspaceBootstrapError( timedOut ? 'setup_command_timeout' : 'setup_command_failed', - `Setup command ${index + 1} ${timedOut ? 'timed out' : 'failed'}`, - createSafeProcessDiagnostic(result) + safeError, + createSetupCommandDiagnostic(command, result), + timedOut ); } + progress?.({ type: 'completed', step: 'setup_commands', stepId, exitCode: 0 }); } logToFile( `bootstrap setup commands finished kiloSessionId=${request.kiloSessionId} count=${setupCommands.length}` ); } +function createSetupCommandDiagnostic(command: string, result: ExecResult): string { + const base = createSafeProcessDiagnostic(result); + const safeCommand = boundedUtf8Tail( + redactSecrets(cleanTerminalOutput(command)).trim(), + SETUP_COMMAND_DIAGNOSTIC_MAX_BYTES + ); + const combinedOutput = [result.stdout, result.stderr].filter(Boolean).join('\n'); + const safeOutput = redactSecrets(cleanTerminalOutput(combinedOutput)).trim(); + const outputTail = boundedUtf8Tail(safeOutput, SETUP_COMMAND_ERROR_OUTPUT_MAX_BYTES); + const parts: string[] = [`command: ${safeCommand}`, base]; + if (outputTail) { + parts.push(`output:\n${outputTail}`); + } + return parts.join(', '); +} + async function downloadAttachment( attachment: WrapperBootstrapAttachment, fetchImpl: typeof fetch, @@ -825,7 +948,7 @@ async function prepareWrapperBootstrapWorkspaceWithinDeadline( if (request.materialized.setupCommands?.length) { progress?.('setup_commands', 'Running setup commands...'); - await runSetupCommands(request, run, !request.workspace.preferSnapshot, progress); + await runSetupCommands(request, run, progress); } signal.throwIfAborted(); diff --git a/services/cloud-agent-next/wrapper/src/state.test.ts b/services/cloud-agent-next/wrapper/src/state.test.ts new file mode 100644 index 0000000000..ef8a5d4eb2 --- /dev/null +++ b/services/cloud-agent-next/wrapper/src/state.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'bun:test'; +import { WrapperState } from './state'; +import type { IngestEvent } from '../../src/shared/protocol'; + +function event(): IngestEvent { + return { streamEventType: 'preparing', data: {}, timestamp: new Date().toISOString() }; +} + +describe('WrapperState send-to-ingest fn lifecycle', () => { + it('clears the send fn it installed', () => { + const state = new WrapperState(); + const sent: IngestEvent[] = []; + const send = (item: IngestEvent) => sent.push(item); + state.setSendToIngestFn(send); + state.clearSendToIngestFn(send); + state.sendToIngest(event()); + expect(sent).toHaveLength(0); + }); + + it('does not clobber a newer connection when a stale channel closes late', () => { + const state = new WrapperState(); + const stale = () => {}; + const sent: IngestEvent[] = []; + state.setSendToIngestFn(stale); + state.setSendToIngestFn(item => sent.push(item)); + state.clearSendToIngestFn(stale); + state.sendToIngest(event()); + expect(sent).toHaveLength(1); + }); +}); diff --git a/services/cloud-agent-next/wrapper/src/state.ts b/services/cloud-agent-next/wrapper/src/state.ts index 9787008b29..79a64f6001 100644 --- a/services/cloud-agent-next/wrapper/src/state.ts +++ b/services/cloud-agent-next/wrapper/src/state.ts @@ -120,6 +120,14 @@ export class WrapperState { this._sendToIngestFn = fn; } + /** + * Clear the send fn only if `fn` is still the active one, so a channel + * closing late cannot clobber a newer connection's send fn. + */ + clearSendToIngestFn(fn: (event: IngestEvent) => void): void { + if (this._sendToIngestFn === fn) this._sendToIngestFn = null; + } + sendToIngest(event: IngestEvent): void { this._sendToIngestFn?.(event); }