diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx
index 7e9fe147c..2b86aadf0 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx
@@ -36,14 +36,12 @@ import { ProductTips, Startup } from './startup';
interface LiveContentProps {
session: TaskSession;
- newTaskHref: string;
onBootStatusChange?: () => void;
onTaskPhaseChange?: (phase: TaskPhase | null) => void;
}
function LiveContent({
session,
- newTaskHref,
onBootStatusChange,
onTaskPhaseChange,
}: LiveContentProps) {
@@ -54,7 +52,6 @@ function LiveContent({
>
@@ -64,7 +61,6 @@ function LiveContent({
function LiveContentInner({
session,
- newTaskHref,
onBootStatusChange,
onTaskPhaseChange,
}: LiveContentProps) {
@@ -271,7 +267,6 @@ function LiveContentInner({
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/page.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/page.client.test.tsx
index f244c3de9..09e36ed69 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/page.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/page.client.test.tsx
@@ -83,16 +83,14 @@ vi.mock('./hooks', () => ({
}));
vi.mock('./startup', () => ({
- Startup: ({ newTaskHref }: { newTaskHref: string }) => (
-
+ Startup: ({ prompt }: { prompt?: string }) => (
+
Startup
-
+
),
ProductTips: () => ,
- SnapshotResumeFailureFooter: ({ newTaskHref }: { newTaskHref: string }) => (
-
- Startup failure
-
+ SnapshotResumeFailureFooter: () => (
+
Startup failure
),
}));
@@ -341,8 +339,8 @@ describe('SandboxPage', () => {
renderPage();
expect(screen.getByTestId('startup')).toHaveAttribute(
- 'href',
- '/?prompt=Fix+the+build&model=openrouter%2Fopenai%2Fgpt-5.4&environmentId=env-1',
+ 'data-prompt',
+ 'Fix the build',
);
expect(screen.queryByTestId('product-tips')).not.toBeInTheDocument();
expect(screen.queryByTestId('draft-prompt-banner')).not.toBeInTheDocument();
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx
index 870da81ba..d7e99c5cb 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx
@@ -14,7 +14,6 @@ import { CircleSlash, TriangleAlert } from '@/components/system';
import {
TaskPayloadKind,
DEFAULT_CODING_HARNESS,
- getLinkedEnvironmentIdFromPayload,
type TaskPhase,
} from '@roomote/types';
@@ -76,24 +75,6 @@ export default function SandboxPage() {
images: session.prompt?.images,
}
: null;
- const newTaskSearchParams = new URLSearchParams();
-
- if (startupPrompt?.text) {
- newTaskSearchParams.set('prompt', startupPrompt.text);
- }
-
- if (task?.model) {
- newTaskSearchParams.set('model', task.model);
- }
-
- const environmentId = getLinkedEnvironmentIdFromPayload(taskRun?.payload);
-
- if (environmentId) {
- newTaskSearchParams.set('environmentId', environmentId);
- }
-
- const newTaskQuery = newTaskSearchParams.toString();
- const newTaskHref = newTaskQuery ? `/?${newTaskQuery}` : '/';
const shouldRenderBootingTranscript =
sessionState === 'booting' &&
(hasTranscriptHistory || hasVisibleSessionPrompt);
@@ -291,12 +272,7 @@ export default function SandboxPage() {
- ) : null
+ taskRun ? : null
}
/>
@@ -312,7 +288,7 @@ export default function SandboxPage() {
@@ -330,7 +306,6 @@ export default function SandboxPage() {
@@ -357,7 +332,6 @@ export default function SandboxPage() {
>
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/startup/Startup.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/startup/Startup.client.test.tsx
new file mode 100644
index 000000000..f5c93d4c2
--- /dev/null
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/startup/Startup.client.test.tsx
@@ -0,0 +1,76 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+
+import { RunStatus } from '@roomote/types';
+
+const { replaceFailedStartMutate, routerPush } = vi.hoisted(() => ({
+ replaceFailedStartMutate: vi.fn(),
+ routerPush: vi.fn(),
+}));
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: routerPush }),
+}));
+
+vi.mock('react-hooks-sse', () => ({
+ SSEProvider: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+vi.mock('@/hooks/task-runs', () => ({
+ useReplaceFailedTaskStart: (options: {
+ onSuccess: (result: { taskId: string; runId: number }) => void;
+ }) => ({
+ isPending: false,
+ mutate: (input: { runId: number }) => {
+ replaceFailedStartMutate(input);
+ options.onSuccess({ taskId: 'replacement-task', runId: 88 });
+ },
+ }),
+}));
+
+vi.mock('./useStartupProgress', () => ({
+ useStartupProgress: () => ({
+ steps: [{ status: RunStatus.Failed, completed: true }],
+ error: 'Provider failed to start',
+ errorCode: null,
+ showLogs: false,
+ sandboxLogs: [],
+ logsConnected: false,
+ logsError: null,
+ }),
+}));
+
+vi.mock('./StartupMessage', () => ({
+ StartupSequence: ({
+ prompt,
+ onRetry,
+ }: {
+ prompt?: string;
+ onRetry?: () => void;
+ }) => (
+
+ {prompt}
+
+
+ ),
+ StartupFailureMessage: () => null,
+}));
+
+import { Startup } from './Startup';
+
+describe('Startup', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('creates a replacement and navigates directly to it', () => {
+ render();
+
+ expect(screen.getByText('Original prompt')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
+
+ expect(replaceFailedStartMutate).toHaveBeenCalledWith({ runId: 77 });
+ expect(routerPush).toHaveBeenCalledWith('/task/replacement-task');
+ });
+});
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/startup/Startup.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/startup/Startup.tsx
index cedc2fbab..224bf3b0f 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/startup/Startup.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/startup/Startup.tsx
@@ -1,12 +1,14 @@
'use client';
import { useCallback } from 'react';
+import { useRouter } from 'next/navigation';
import { SSEProvider } from 'react-hooks-sse';
import type { RunStatus as RunStatusValue } from '@roomote/types';
import type { TaskRun } from '@roomote/db';
import { getTaskRunError } from '@/lib/task-run-errors';
+import { useReplaceFailedTaskStart } from '@/hooks/task-runs';
import { StartupFailureMessage, StartupSequence } from './StartupMessage';
import { useStartupProgress } from './useStartupProgress';
@@ -14,16 +16,20 @@ import { useStartupProgress } from './useStartupProgress';
interface StartupProps {
runId: number;
initialTaskRun?: TaskRun;
- newTaskHref: string;
+ prompt?: string;
onStatusChange?: (status: RunStatusValue) => void;
}
export const Startup = ({
runId,
initialTaskRun,
- newTaskHref,
+ prompt,
onStatusChange,
}: StartupProps) => {
+ const router = useRouter();
+ const replaceFailedStart = useReplaceFailedTaskStart({
+ onSuccess: ({ taskId }) => router.push(`/task/${taskId}`),
+ });
const eventSource = useCallback(() => {
const eventSource = new EventSource(`/api/task-runs/${runId}/stream`, {
withCredentials: true,
@@ -39,7 +45,9 @@ export const Startup = ({
replaceFailedStart.mutate({ runId })}
+ retryPending={replaceFailedStart.isPending}
onStatusChange={onStatusChange}
/>
@@ -49,14 +57,18 @@ export const Startup = ({
interface StartupInnerProps {
runId: number;
initialTaskRun?: TaskRun;
- newTaskHref: string;
+ prompt?: string;
+ onRetry: () => void;
+ retryPending: boolean;
onStatusChange?: (status: RunStatusValue) => void;
}
const StartupInner = ({
runId,
initialTaskRun,
- newTaskHref,
+ prompt,
+ onRetry,
+ retryPending,
onStatusChange,
}: StartupInnerProps) => {
const {
@@ -77,23 +89,22 @@ const StartupInner = ({
logs={showLogs ? sandboxLogs : undefined}
logsConnected={logsConnected}
logsError={logsError}
- newTaskHref={newTaskHref}
+ prompt={prompt}
+ onRetry={onRetry}
+ retryPending={retryPending}
/>
);
};
interface SnapshotResumeFailureFooterProps {
taskRun: Pick;
- newTaskHref: string;
}
export const SnapshotResumeFailureFooter = ({
taskRun,
- newTaskHref,
}: SnapshotResumeFailureFooterProps) => (
);
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.client.test.tsx
index 6dc404918..71095ef3a 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.client.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import { RunStatus } from '@roomote/types';
@@ -40,6 +40,18 @@ vi.mock('@/components/system', () => ({
{children}
),
+ CopyIconButton: ({
+ content,
+ ...props
+ }: {
+ content: string;
+ 'aria-label'?: string;
+ }) => (
+
+ ),
+ Loader2: () => null,
BotMessageSquare: () => null,
Plug: () => null,
Ghost: () => null,
@@ -128,39 +140,38 @@ describe('StartupSequence', () => {
expect(screen.getByText('Warming up my GPUs')).toBeInTheDocument();
});
- it('links startup failures to a pre-filled new task', () => {
+ it('retries failed starts inline and keeps the original prompt copyable', () => {
+ const onRetry = vi.fn();
render(
,
);
- expect(
- screen.getByRole('link', { name: 'Try in a new task' }),
- ).toHaveAttribute(
- 'href',
- '/?prompt=Fix+the+build&model=openrouter%2Fopenai%2Fgpt-5.4&environmentId=env-1',
+ expect(screen.getByText('Original prompt')).toBeInTheDocument();
+ expect(screen.getByText('Fix the build')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Copy prompt' })).toHaveAttribute(
+ 'data-content',
+ 'Fix the build',
);
- expect(
- screen.queryByRole('button', { name: 'Retry' }),
- ).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
+ expect(onRetry).toHaveBeenCalledOnce();
+ expect(screen.queryByText('Try in a new task')).not.toBeInTheDocument();
});
- it('does not render the removed prompt preview for failed starts', () => {
+ it('disables Retry while replacement creation is pending', () => {
render(
,
);
- expect(screen.queryByText('Your prompt')).not.toBeInTheDocument();
- expect(screen.queryByText('Fix the build')).not.toBeInTheDocument();
- expect(
- screen.getByRole('link', { name: 'Try in a new task' }),
- ).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Retry' })).toBeDisabled();
});
it('renders startup content inline without its own scroll surface', () => {
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.tsx
index 3d2d2dcdb..1d5f5eda8 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.tsx
@@ -1,19 +1,19 @@
'use client';
-import Link from 'next/link';
-
import type { LucideIcon } from '@/components/system';
import {
Check,
HardDriveUpload,
Hourglass,
Button,
+ CopyIconButton,
BotMessageSquare,
Plug,
Ghost,
Drum,
ThumbsDown,
SquareDashedMousePointer,
+ Loader2,
} from '@/components/system';
import { RunStatus } from '@roomote/types';
@@ -126,14 +126,46 @@ interface StartupErrorMessageProps {
error?: string;
/** Machine-readable failure category persisted with the run. */
errorCode?: string | null;
- newTaskHref?: string;
+ prompt?: string;
+ onRetry?: () => void;
+ retryPending?: boolean;
}
+const FailedStartActions = ({
+ prompt,
+ onRetry,
+ retryPending = false,
+}: Pick) => (
+
+ {prompt ? (
+
+
+ Original prompt
+
+
+
{prompt}
+
+ ) : null}
+ {onRetry ? (
+
+ ) : null}
+
+);
+
export const StartupFailureMessage = ({
status,
error,
errorCode,
- newTaskHref,
+ prompt,
+ onRetry,
+ retryPending,
}: StartupErrorMessageProps) => {
const isFailed = status === RunStatus.Failed;
const isCanceled = status === RunStatus.Canceled;
@@ -150,13 +182,13 @@ export const StartupFailureMessage = ({
{displayError}
- {newTaskHref && (
-
-
-
- )}
+ {isFailed ? (
+
+ ) : null}
@@ -172,13 +204,11 @@ export const StartupFailureMessage = ({
There was an error starting this environment:
- {newTaskHref && (
-
-
-
- )}
+
@@ -213,7 +243,9 @@ interface StartupSequenceProps {
logs?: SandboxLogEntry[];
logsConnected?: boolean;
logsError?: string | null;
- newTaskHref?: string;
+ prompt?: string;
+ onRetry?: () => void;
+ retryPending?: boolean;
}
export const StartupSequence = ({
@@ -223,7 +255,9 @@ export const StartupSequence = ({
logs,
logsConnected = true,
logsError = null,
- newTaskHref,
+ prompt,
+ onRetry,
+ retryPending,
}: StartupSequenceProps) => {
const lastStep = steps[steps.length - 1];
const status = lastStep?.status ?? RunStatus.Pending;
@@ -275,7 +309,9 @@ export const StartupSequence = ({
status={status}
error={error}
errorCode={errorCode}
- newTaskHref={newTaskHref}
+ prompt={prompt}
+ onRetry={onRetry}
+ retryPending={retryPending}
/>
);
diff --git a/apps/web/src/hooks/task-runs/index.ts b/apps/web/src/hooks/task-runs/index.ts
index 918ff90f6..5aeac6cb8 100644
--- a/apps/web/src/hooks/task-runs/index.ts
+++ b/apps/web/src/hooks/task-runs/index.ts
@@ -1,5 +1,6 @@
export { useCancelTaskRun } from './useCancelTaskRun';
export { useCreateStandardTaskRun } from './useCreateStandardTaskRun';
export { useRetryFailedTaskStart } from './useRetryFailedTaskStart';
+export { useReplaceFailedTaskStart } from './useReplaceFailedTaskStart';
export { useRouteHomeTask } from './useRouteHomeTask';
export { useStartFastSession } from './useStartFastSession';
diff --git a/apps/web/src/hooks/task-runs/useReplaceFailedTaskStart.ts b/apps/web/src/hooks/task-runs/useReplaceFailedTaskStart.ts
new file mode 100644
index 000000000..609c4ace4
--- /dev/null
+++ b/apps/web/src/hooks/task-runs/useReplaceFailedTaskStart.ts
@@ -0,0 +1,33 @@
+'use client';
+
+import { useMutation } from '@tanstack/react-query';
+import { toast } from 'sonner';
+
+import { useTRPC } from '@/trpc/client';
+
+export function useReplaceFailedTaskStart(options?: {
+ onSuccess?: (result: { runId: number; taskId: string }) => void;
+}) {
+ const trpc = useTRPC();
+
+ return useMutation(
+ trpc.taskRuns.replaceFailedStart.mutationOptions({
+ onSuccess: (result) => {
+ if (result.success) {
+ options?.onSuccess?.({ runId: result.id, taskId: result.taskId });
+ return;
+ }
+
+ toast.error('Oops', { description: result.error });
+ },
+ onError: (error) => {
+ toast.error('Oops', {
+ description:
+ error instanceof Error
+ ? error.message
+ : 'An unknown error occurred',
+ });
+ },
+ }),
+ );
+}
diff --git a/apps/web/src/trpc/commands/task-runs/index.test.ts b/apps/web/src/trpc/commands/task-runs/index.test.ts
index 2e2c5ff7a..8bb97526c 100644
--- a/apps/web/src/trpc/commands/task-runs/index.test.ts
+++ b/apps/web/src/trpc/commands/task-runs/index.test.ts
@@ -1,9 +1,13 @@
-import { ALL_REPOSITORIES, TaskPayloadKind } from '@roomote/types';
+import { ALL_REPOSITORIES, RunStatus, TaskPayloadKind } from '@roomote/types';
import type { UserAuthSuccess } from '@/types';
const {
mockEnqueueTask,
+ mockCanRetryFailedStart,
+ mockFindFastSession,
+ mockFindReplacement,
+ mockFindTaskRun,
mockGetRepositories,
mockDbWhere,
mockDbSelect,
@@ -15,6 +19,10 @@ const {
mockSendSandboxPrompt,
} = vi.hoisted(() => ({
mockEnqueueTask: vi.fn(),
+ mockCanRetryFailedStart: vi.fn(),
+ mockFindFastSession: vi.fn(),
+ mockFindReplacement: vi.fn(),
+ mockFindTaskRun: vi.fn(),
mockGetRepositories: vi.fn(),
mockDbWhere: vi.fn(),
mockDbSelect: vi.fn(),
@@ -28,7 +36,14 @@ const {
vi.mock('@roomote/cloud-agents/server', () => ({
buildSlackRoutingContext: vi.fn(),
+ canRetryFailedStart: (...args: unknown[]) => mockCanRetryFailedStart(...args),
+ DeploymentReadOnlyError: class DeploymentReadOnlyError extends Error {
+ code = 'DEPLOYMENT_READ_ONLY';
+ },
enqueueTask: (...args: unknown[]) => mockEnqueueTask(...args),
+ fastAgentConversationRepository: {
+ findById: (...args: unknown[]) => mockFindFastSession(...args),
+ },
getTaskUrl: vi.fn(() => 'https://roomote.test/tasks/task-123'),
routeTask: vi.fn(),
}));
@@ -41,7 +56,16 @@ vi.mock('@roomote/db/server', () => ({
findFirst: vi.fn(async () => null),
},
taskRuns: {
- findFirst: vi.fn(async () => null),
+ findFirst: (...args: unknown[]) => {
+ const options = args[0] as
+ | { columns?: Record }
+ | undefined;
+ return options?.columns?.id === true &&
+ options.columns.taskId === true &&
+ Object.keys(options.columns).length === 2
+ ? mockFindReplacement(...args)
+ : mockFindTaskRun(...args);
+ },
findMany: vi.fn(async () => []),
},
slackInstallations: {
@@ -116,7 +140,11 @@ vi.mock('../sandbox-session', () => ({
mockSendSandboxPrompt(...args),
}));
-import { createStandardTaskRunCommand, startTaskGoalCommand } from './index';
+import {
+ createFailedStartReplacementTaskRunCommand,
+ createStandardTaskRunCommand,
+ startTaskGoalCommand,
+} from './index';
const auth = {
success: true,
@@ -259,6 +287,10 @@ describe('createStandardTaskRunCommand', () => {
mockDbWhere.mockResolvedValue([]);
// Shared resolver defaults to unresolved; the environment test overrides it.
mockResolveWorkspaceProvider.mockResolvedValue({});
+ mockFindTaskRun.mockResolvedValue(null);
+ mockFindFastSession.mockResolvedValue(null);
+ mockFindReplacement.mockResolvedValue(null);
+ mockCanRetryFailedStart.mockResolvedValue(true);
mockSuccessfulEnqueue();
});
@@ -479,4 +511,229 @@ describe('createStandardTaskRunCommand', () => {
}),
);
});
+
+ it('creates an immediate replacement with original launch settings and canonical Fast metadata', async () => {
+ const sourceConversation = {
+ surface: 'slack' as const,
+ workspaceId: 'workspace-1',
+ conversationId: 'thread-1',
+ replyTarget: { channelId: 'channel-1', threadId: 'thread-1' },
+ };
+ const canonicalConversation = {
+ ...sourceConversation,
+ replyTarget: { channelId: 'channel-2', threadId: 'thread-1' },
+ };
+ mockFindTaskRun.mockResolvedValue({
+ id: 77,
+ taskId: 'source-task',
+ status: RunStatus.Failed,
+ payloadKind: TaskPayloadKind.StandardTask,
+ harness: 'opencode-server',
+ vendor: 'modal',
+ payload: {
+ repo: ALL_REPOSITORIES,
+ environmentId: 'original-environment',
+ description: 'Original prompt',
+ communicationSourceEventId: 'gateway-event-1',
+ launchIdempotencyKey: 'original-launch',
+ fastAgentSessionId: '11111111-1111-4111-8111-111111111111',
+ fastAgentParent: {
+ sessionId: '11111111-1111-4111-8111-111111111111',
+ conversation: sourceConversation,
+ },
+ },
+ error: 'Provider failed to start',
+ result: null,
+ });
+ mockResolveTaskByIdAccess.mockResolvedValue({
+ kind: 'resolved',
+ task: {
+ id: 'source-task',
+ model: 'openrouter/openai/gpt-5.4',
+ },
+ });
+ mockFindFastSession.mockResolvedValue({
+ id: '22222222-2222-4222-8222-222222222222',
+ userId: auth.userId,
+ conversation: canonicalConversation,
+ });
+
+ const result = await createFailedStartReplacementTaskRunCommand(auth, {
+ runId: 77,
+ });
+
+ expect(result.success).toBe(true);
+ expect(mockFindFastSession).toHaveBeenCalledWith({
+ id: '11111111-1111-4111-8111-111111111111',
+ fallbackConversation: sourceConversation,
+ });
+ expect(mockEnqueueTask).toHaveBeenCalledWith(
+ expect.objectContaining({
+ task: expect.objectContaining({
+ computeProvider: 'modal',
+ payload: expect.objectContaining({
+ environmentId: 'original-environment',
+ description: 'Original prompt',
+ communicationContextInherited: true,
+ fastAgentSessionId: '22222222-2222-4222-8222-222222222222',
+ fastAgentParent: {
+ sessionId: '22222222-2222-4222-8222-222222222222',
+ conversation: canonicalConversation,
+ },
+ harnessModelOverrides: expect.objectContaining({
+ 'opencode-server': 'openrouter/openai/gpt-5.4',
+ }),
+ }),
+ }),
+ }),
+ );
+ const enqueuedPayload = mockEnqueueTask.mock.calls[0]?.[0]?.task?.payload;
+ expect(enqueuedPayload).not.toHaveProperty('communicationSourceEventId');
+ expect(enqueuedPayload).toHaveProperty(
+ 'launchIdempotencyKey',
+ 'failed-start-replacement:77',
+ );
+ });
+
+ it('strips client-supplied Fast linkage from ordinary web launches', async () => {
+ await createStandardTaskRunCommand(auth, {
+ payload: {
+ repo: ALL_REPOSITORIES,
+ description: 'Spoof linkage',
+ communicationContextInherited: true,
+ fastAgentSessionId: '11111111-1111-4111-8111-111111111111',
+ fastAgentParent: {
+ sessionId: '11111111-1111-4111-8111-111111111111',
+ conversation: {
+ surface: 'web',
+ workspaceId: 'other-user',
+ conversationId: 'other-session',
+ },
+ },
+ },
+ });
+
+ const enqueuedPayload = mockEnqueueTask.mock.calls[0]?.[0]?.task?.payload;
+ expect(enqueuedPayload).not.toHaveProperty('communicationContextInherited');
+ expect(enqueuedPayload).not.toHaveProperty('fastAgentSessionId');
+ expect(enqueuedPayload).not.toHaveProperty('fastAgentParent');
+ });
+
+ it('rejects recovery linkage to another user Fast session', async () => {
+ mockFindTaskRun.mockResolvedValue({
+ id: 77,
+ taskId: 'source-task',
+ status: RunStatus.Failed,
+ payloadKind: TaskPayloadKind.StandardTask,
+ harness: 'opencode-server',
+ vendor: 'modal',
+ payload: {
+ repo: ALL_REPOSITORIES,
+ description: 'Retry',
+ fastAgentParent: {
+ sessionId: '11111111-1111-4111-8111-111111111111',
+ conversation: {
+ surface: 'web',
+ workspaceId: 'workspace-1',
+ conversationId: 'session-1',
+ },
+ },
+ },
+ error: 'Provider failed to start',
+ result: null,
+ });
+ mockResolveTaskByIdAccess.mockResolvedValue({
+ kind: 'resolved',
+ task: {
+ id: 'source-task',
+ model: 'openrouter/openai/gpt-5.4',
+ },
+ });
+ mockFindFastSession.mockResolvedValue({
+ id: '11111111-1111-4111-8111-111111111111',
+ userId: 'other-user',
+ conversation: {
+ surface: 'web',
+ workspaceId: 'workspace-1',
+ conversationId: 'session-1',
+ },
+ });
+
+ await expect(
+ createFailedStartReplacementTaskRunCommand(auth, { runId: 77 }),
+ ).resolves.toEqual({
+ success: false,
+ error: 'Failed task start is not linked to your Fast session.',
+ });
+ expect(mockEnqueueTask).not.toHaveBeenCalled();
+ });
+
+ it('returns an existing replacement instead of launching a duplicate', async () => {
+ mockFindTaskRun.mockResolvedValue({
+ id: 77,
+ taskId: 'source-task',
+ status: RunStatus.Failed,
+ payloadKind: TaskPayloadKind.StandardTask,
+ harness: 'opencode-server',
+ vendor: 'modal',
+ payload: {
+ repo: ALL_REPOSITORIES,
+ description: 'Original prompt',
+ },
+ });
+ mockResolveTaskByIdAccess.mockResolvedValue({
+ kind: 'resolved',
+ task: {
+ id: 'source-task',
+ model: 'openrouter/openai/gpt-5.4',
+ },
+ });
+ mockFindReplacement.mockResolvedValue({
+ id: 88,
+ taskId: 'replacement-task',
+ });
+
+ await expect(
+ createFailedStartReplacementTaskRunCommand(auth, { runId: 77 }),
+ ).resolves.toEqual({
+ success: true,
+ id: 88,
+ taskId: 'replacement-task',
+ });
+ expect(mockEnqueueTask).not.toHaveBeenCalled();
+ });
+
+ it('recovers the winning replacement after a concurrent uniqueness race', async () => {
+ mockFindTaskRun.mockResolvedValue({
+ id: 77,
+ taskId: 'source-task',
+ status: RunStatus.Failed,
+ payloadKind: TaskPayloadKind.StandardTask,
+ harness: 'opencode-server',
+ vendor: 'modal',
+ payload: {
+ repo: ALL_REPOSITORIES,
+ description: 'Original prompt',
+ },
+ });
+ mockResolveTaskByIdAccess.mockResolvedValue({
+ kind: 'resolved',
+ task: {
+ id: 'source-task',
+ model: 'openrouter/openai/gpt-5.4',
+ },
+ });
+ mockFindReplacement
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce({ id: 88, taskId: 'replacement-task' });
+ mockEnqueueTask.mockRejectedValue(new Error('duplicate key'));
+
+ await expect(
+ createFailedStartReplacementTaskRunCommand(auth, { runId: 77 }),
+ ).resolves.toEqual({
+ success: true,
+ id: 88,
+ taskId: 'replacement-task',
+ });
+ });
});
diff --git a/apps/web/src/trpc/commands/task-runs/index.ts b/apps/web/src/trpc/commands/task-runs/index.ts
index a05deb488..f7d083b63 100644
--- a/apps/web/src/trpc/commands/task-runs/index.ts
+++ b/apps/web/src/trpc/commands/task-runs/index.ts
@@ -1,6 +1,8 @@
import {
ALL_REPOSITORIES,
activeRunStatuses,
+ buildFastAgentChildTaskMetadata,
+ getFastAgentParentFromPayload,
type TaskPayload,
type ComputeProvider,
type LaunchCodingHarness,
@@ -14,8 +16,10 @@ import {
import {
type RoutingDecision,
buildSlackRoutingContext,
+ canRetryFailedStart,
DeploymentReadOnlyError,
enqueueTask,
+ fastAgentConversationRepository,
getTaskUrl,
routeTask,
} from '@roomote/cloud-agents/server';
@@ -29,6 +33,7 @@ import {
markTaskStartParallelCountEndedAt,
prepareTaskGoalActivation,
slackInstallations,
+ sql,
taskRuns,
tasks,
} from '@roomote/db/server';
@@ -119,6 +124,7 @@ type CreateStandardTaskRunInput = {
harness?: LaunchCodingHarness;
model?: string;
computeProvider?: ComputeProvider;
+ failedStartSourceRunId?: number;
sourceTaskId?: string;
sourceArtifactId?: string;
sourceArtifactPath?: string;
@@ -126,6 +132,148 @@ type CreateStandardTaskRunInput = {
payload: TaskPayload;
};
+function stripClientFastAgentLinkage(
+ payload: TaskPayload,
+): TaskPayload {
+ const sanitized = { ...payload };
+ delete sanitized.fastAgentParent;
+ delete sanitized.fastAgentSessionId;
+ delete sanitized.communicationContextInherited;
+ return sanitized;
+}
+
+function getFailedStartReplacementKey(sourceRunId: number): string {
+ return `failed-start-replacement:${sourceRunId}`;
+}
+
+async function findFailedStartReplacement(
+ launchIdempotencyKey: string,
+): Promise {
+ const existing = await db.query.taskRuns.findFirst({
+ where: sql`${taskRuns.payload}->>'launchIdempotencyKey' = ${launchIdempotencyKey}`,
+ columns: { id: true, taskId: true },
+ });
+
+ return existing
+ ? { success: true, id: existing.id, taskId: existing.taskId }
+ : null;
+}
+
+async function resolveFailedStartFastAgentMetadata({
+ auth,
+ sourceRunId,
+}: {
+ auth: UserAuthSuccess;
+ sourceRunId?: number;
+}): Promise | null> {
+ if (sourceRunId === undefined) {
+ return null;
+ }
+
+ const sourceRun = await db.query.taskRuns.findFirst({
+ where: eq(taskRuns.id, sourceRunId),
+ columns: {
+ taskId: true,
+ status: true,
+ payloadKind: true,
+ payload: true,
+ error: true,
+ result: true,
+ },
+ });
+
+ if (
+ !sourceRun ||
+ sourceRun.payloadKind !== TaskPayloadKind.StandardTask ||
+ sourceRun.status !== RunStatus.Failed
+ ) {
+ throw new Error('Failed task start not found.');
+ }
+
+ const sourceTaskAccess = await resolveTaskByIdAccessCommand(auth, {
+ taskId: sourceRun.taskId,
+ });
+ if (sourceTaskAccess.kind !== 'resolved') {
+ throw new Error('Failed task start not found.');
+ }
+
+ const parent = getFastAgentParentFromPayload(sourceRun.payload);
+ if (!parent) {
+ return null;
+ }
+
+ const session = await fastAgentConversationRepository.findById({
+ id: parent.sessionId,
+ fallbackConversation: parent.conversation,
+ });
+ if (!session || session.userId !== auth.userId) {
+ throw new Error('Failed task start is not linked to your Fast session.');
+ }
+
+ return buildFastAgentChildTaskMetadata({
+ sessionId: session.id,
+ conversation: session.conversation,
+ });
+}
+
+export async function createFailedStartReplacementTaskRunCommand(
+ auth: UserAuthSuccess,
+ input: { runId: number },
+): Promise {
+ try {
+ const sourceRun = await db.query.taskRuns.findFirst({
+ where: eq(taskRuns.id, input.runId),
+ });
+ if (
+ !sourceRun ||
+ sourceRun.payloadKind !== TaskPayloadKind.StandardTask ||
+ !(await canRetryFailedStart(sourceRun))
+ ) {
+ return { success: false, error: 'Failed task start not found.' };
+ }
+
+ const sourceTaskAccess = await resolveTaskByIdAccessCommand(auth, {
+ taskId: sourceRun.taskId,
+ });
+ if (sourceTaskAccess.kind !== 'resolved') {
+ return { success: false, error: 'Failed task start not found.' };
+ }
+ if (!sourceTaskAccess.task.model) {
+ return { success: false, error: 'Failed task model not found.' };
+ }
+
+ const payload = { ...sourceRun.payload };
+ delete payload.communicationSourceEventId;
+ const launchIdempotencyKey = getFailedStartReplacementKey(sourceRun.id);
+ payload.launchIdempotencyKey = launchIdempotencyKey;
+
+ const existingReplacement =
+ await findFailedStartReplacement(launchIdempotencyKey);
+ if (existingReplacement) {
+ return existingReplacement;
+ }
+
+ const result = await createStandardTaskRunCommand(auth, {
+ harness: sourceRun.harness,
+ model: sourceTaskAccess.task.model,
+ computeProvider: sourceRun.vendor ?? undefined,
+ failedStartSourceRunId: sourceRun.id,
+ payload,
+ });
+
+ if (result.success) {
+ return result;
+ }
+
+ return (await findFailedStartReplacement(launchIdempotencyKey)) ?? result;
+ } catch (error) {
+ console.error(error);
+ return error instanceof Error
+ ? { success: false, error: error.message }
+ : { success: false, error: 'An unknown error occurred.' };
+ }
+}
+
function getManualTaskRepositoryFullNames(
payload: TaskPayload,
) {
@@ -403,7 +551,9 @@ export async function createStandardTaskRunCommand(
input: CreateStandardTaskRunInput,
): Promise {
try {
- if (!input.payload.environmentId && !input.payload.repo) {
+ const payload = stripClientFastAgentLinkage(input.payload);
+
+ if (!payload.environmentId && !payload.repo) {
return {
success: false,
error: 'Select an environment before starting a task.',
@@ -419,9 +569,8 @@ export async function createStandardTaskRunCommand(
throw new Error(evalSelection.error);
}
- const selectedRepositoryFullNames = getManualTaskRepositoryFullNames(
- input.payload,
- );
+ const selectedRepositoryFullNames =
+ getManualTaskRepositoryFullNames(payload);
const availableRepositories =
selectedRepositoryFullNames.length === 0
? []
@@ -430,26 +579,29 @@ export async function createStandardTaskRunCommand(
selectedRepositoryFullNames.includes(repository.fullName),
);
const sourceControlProvider =
- input.payload.sourceControlProvider ??
+ payload.sourceControlProvider ??
resolveSelectedRepositorySourceControlProvider(
selectedRepositories,
selectedRepositoryFullNames,
) ??
- (await resolveEnvironmentSourceControlProvider(
- input.payload.environmentId,
- ));
+ (await resolveEnvironmentSourceControlProvider(payload.environmentId));
+ const fastAgentMetadata = await resolveFailedStartFastAgentMetadata({
+ auth,
+ sourceRunId: input.failedStartSourceRunId,
+ });
const task: StandardTask = {
harness: evalSelection.harness ?? input.harness,
computeProvider: input.computeProvider,
type: TaskPayloadKind.StandardTask,
payload: {
- ...input.payload,
+ ...payload,
+ ...(fastAgentMetadata ?? {}),
...(sourceControlProvider ? { sourceControlProvider } : {}),
...(evalSelection.harnessModelOverrides
? {
harnessModelOverrides: {
- ...(input.payload.harnessModelOverrides ?? {}),
+ ...(payload.harnessModelOverrides ?? {}),
...evalSelection.harnessModelOverrides,
},
}
diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts
index 822f503b9..2f9dc5233 100644
--- a/apps/web/src/trpc/routers/_app.ts
+++ b/apps/web/src/trpc/routers/_app.ts
@@ -114,6 +114,7 @@ import {
} from '../commands/source-control';
import {
routeHomeTaskCommand,
+ createFailedStartReplacementTaskRunCommand,
createStandardTaskRunCommand,
cancelTaskRunCommand,
retryFailedTaskStartCommand,
@@ -1065,13 +1066,24 @@ export const appRouter = createRouter({
sourceArtifactId: z.string().uuid().optional(),
sourceArtifactPath: z.string().optional(),
sourceArtifactVersion: z.number().int().optional(),
- payload: standardTaskPayloadSchema,
+ payload: standardTaskPayloadSchema.omit({
+ communicationContextInherited: true,
+ fastAgentParent: true,
+ fastAgentSessionId: true,
+ launchIdempotencyKey: true,
+ }),
}),
)
.mutation(({ ctx: { auth }, input }) =>
createStandardTaskRunCommand(auth, input),
),
+ replaceFailedStart: protectedProcedure
+ .input(z.object({ runId: z.number().int().positive() }))
+ .mutation(({ ctx: { auth }, input }) =>
+ createFailedStartReplacementTaskRunCommand(auth, input),
+ ),
+
cancel: protectedProcedure
.input(
z.object({