From 78a0ebba1a2b2fbc69b60fddf8371e2e54dd9988 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 27 Aug 2026 14:47:45 +0000 Subject: [PATCH] feat: retry failed starts in replacement tasks --- .../(sandbox)/task/[taskId]/LiveContent.tsx | 5 - .../task/[taskId]/page.client.test.tsx | 16 +- .../src/app/(sandbox)/task/[taskId]/page.tsx | 30 +- .../[taskId]/startup/Startup.client.test.tsx | 76 +++++ .../task/[taskId]/startup/Startup.tsx | 29 +- .../startup/StartupMessage.client.test.tsx | 47 ++-- .../task/[taskId]/startup/StartupMessage.tsx | 78 ++++-- apps/web/src/hooks/task-runs/index.ts | 1 + .../task-runs/useReplaceFailedTaskStart.ts | 33 +++ .../src/trpc/commands/task-runs/index.test.ts | 263 +++++++++++++++++- apps/web/src/trpc/commands/task-runs/index.ts | 172 +++++++++++- apps/web/src/trpc/routers/_app.ts | 14 +- 12 files changed, 660 insertions(+), 104 deletions(-) create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/startup/Startup.client.test.tsx create mode 100644 apps/web/src/hooks/task-runs/useReplaceFailedTaskStart.ts 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({