Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion apps/controller/src/BaseController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ export abstract class BaseController {
taskRun: TaskRun,
error: unknown,
): Promise<void> {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorMessage = formatSpawnErrorMessage(error);

captureControllerException(error, {
runId: taskRun.id,
Expand Down Expand Up @@ -544,3 +544,35 @@ export abstract class BaseController {
throw error;
}
}

/**
* Build the user-visible spawn failure string. Prefer stderr/stdout from
* child-process errors so daemon causes (missing image, pull denied) are not
* buried under "Command failed: docker run …" argv dumps.
*/
export function formatSpawnErrorMessage(error: unknown): string {
if (!(error instanceof Error)) {
return String(error);
}

const errorRecord = error as Error & {
stderr?: unknown;
stdout?: unknown;
};
const stderr =
typeof errorRecord.stderr === 'string' ? errorRecord.stderr.trim() : '';
const stdout =
typeof errorRecord.stdout === 'string' ? errorRecord.stdout.trim() : '';
const message = error.message.trim();

const hasStructuredOutput = Boolean(stderr || stdout);
const messageAlreadyIncludesOutput =
(stderr && message.includes(stderr)) ||
(stdout && message.includes(stdout));

if (!hasStructuredOutput || messageAlreadyIncludesOutput) {
return message || stderr || stdout || 'Unknown spawn error';
}

return [message, stderr, stdout].filter(Boolean).join('\n');
}
33 changes: 33 additions & 0 deletions apps/controller/src/__tests__/BaseController.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,92 @@ export async function docker(
return '';
}

throw error;
throw formatDockerCommandError(args, error);
}
}

/**
* Prefer docker daemon stderr/stdout over Node's default "Command failed:
* docker run ..." dump so spawn failures keep the actionable cause (image
* missing, pull denied, etc.) without burying it under the full argv list.
*/
export function formatDockerCommandError(
args: string[],
error: unknown,
): Error {
const stderr = getStringErrorField(error, 'stderr');
const stdout = getStringErrorField(error, 'stdout');
const originalMessage =
error instanceof Error ? error.message.trim() : String(error).trim();
const details =
[stderr, stdout].filter(Boolean).join('\n').trim() ||
stripDockerCommandFailedPrefix(originalMessage) ||
originalMessage ||
'unknown docker error';
const commandSummary = summarizeDockerCommand(args);
const message = `Docker command failed (${commandSummary}):\n${details}`;

if (error instanceof Error) {
const wrapped = new Error(message, { cause: error });
wrapped.name = error.name;
// Preserve Node exec stderr/stdout so callers that inspect those fields
// (disk-limit detection, not-found checks) keep working.
Object.assign(wrapped, {
stderr: getRawErrorField(error, 'stderr'),
stdout: getRawErrorField(error, 'stdout'),
code: getRawErrorField(error, 'code'),
signal: getRawErrorField(error, 'signal'),
killed: getRawErrorField(error, 'killed'),
cmd: getRawErrorField(error, 'cmd'),
});
return wrapped;
}

return new Error(message);
}

function getStringErrorField(error: unknown, field: string): string {
const value = getRawErrorField(error, field);
return typeof value === 'string' ? value.trim() : '';
}

function getRawErrorField(error: unknown, field: string): unknown {
if (!error || typeof error !== 'object') {
return undefined;
}

return (error as Record<string, unknown>)[field];
}

function stripDockerCommandFailedPrefix(message: string): string {
return message
.replace(/^Command failed:\s*docker(?:\s+[^\n]+)?\n?/i, '')
.trim();
}

function summarizeDockerCommand(args: string[]): string {
const verb = args[0]?.trim();
const nonOptionArgs = args.filter(
(arg) => arg.length > 0 && !arg.startsWith('-'),
);
// Prefer an image-like arg (contains ':' or '/') over container names.
const imageLike = [...nonOptionArgs]
.reverse()
.find((arg) => arg.includes(':') || arg.includes('/'));

if (verb && imageLike) {
return `docker ${verb} ${imageLike}`;
}

if (verb && nonOptionArgs[1]) {
return `docker ${verb} ${nonOptionArgs[1]}`;
}

if (verb) {
return `docker ${verb}`;
}

return 'docker';
}

export function getDockerTaskNetworkName(taskRunId: number): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,38 @@ export const StepDequeued: Story = {
),
};

/** Active boot step that has been waiting long enough to show elapsed time. */
export const StepDequeuedStillBooting: Story = {
name: 'Step – Dequeued still booting',
render: () => (
<StartupSequence
steps={[mockStep(RunStatus.Dequeued, false)]}
activeStepSinceMs={Date.now() - 45_000}
/>
),
};

/** Failed boot with a missing/unpullable Docker worker image error. */
export const FailedMissingWorkerImage: Story = {
name: 'Failed – Missing worker image',
render: () => (
<StartupSequence
steps={[
mockStep(RunStatus.Pending, true),
mockStep(RunStatus.Dequeued, true),
mockStep(RunStatus.Failed, true),
]}
error={`Docker command failed (docker run roomote-worker:local):
Unable to find image 'roomote-worker:local' locally
docker: Error response from daemon: pull access denied for roomote-worker, repository does not exist or may require 'docker login'`}
retryAction={{
label: 'Retry',
onClick: () => undefined,
}}
/>
),
};

/** A completed step (no shimmer animation). */
export const StepCompletedProcessing: Story = {
name: 'Step – Processing (completed)',
Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/app/(sandbox)/task/[taskId]/startup/Startup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,15 @@ const StartupInner = ({
const restoreSnapshot = useRestoreTaskRunSnapshot();
const retryFailedStart = useRetryFailedTaskStart();

const { steps, error, showLogs, sandboxLogs, logsConnected, logsError } =
useStartupProgress({ runId, initialTaskRun, onStatusChange });
const {
steps,
error,
showLogs,
sandboxLogs,
logsConnected,
logsError,
activeStepSinceMs,
} = useStartupProgress({ runId, initialTaskRun, onStatusChange });

return (
<StartupSequence
Expand All @@ -163,6 +170,7 @@ const StartupInner = ({
logsConnected={logsConnected}
logsError={logsError}
prompt={prompt}
activeStepSinceMs={activeStepSinceMs}
retryAction={buildRetryAction({
taskId,
taskRun: initialTaskRun,
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading