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)}
+
+
+
+
+
+ );
+}
+
+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 && (
+
+ )}
+
+ {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 => (
+ -
+
+
+ ))}
+
+ );
+}
+
+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 (
+
+ {lines.map((line, index) => (
+
+ {line || '\u00A0'}
+
+ ))}
+
+ );
+}
+
+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);
}