diff --git a/run.json b/run.json index 62c15cb9c..8c517d468 100644 --- a/run.json +++ b/run.json @@ -521,7 +521,7 @@ "kind": "running" }, "status_updated_at": "2026-05-23T10:02:04.125640Z", - "last_event_at": "2026-05-23T10:43:18.890629Z", + "last_event_at": "2026-05-23T10:59:48.234377Z", "pending_control": null, "checkpoints": [ { @@ -769,9 +769,9 @@ } }, { - "seq": 0, + "seq": 984, "checkpoint": { - "timestamp": "2026-05-23T10:43:19.417237Z", + "timestamp": "2026-05-23T10:43:26.144367Z", "current_node": "implement", "completed_nodes": [ "start", @@ -782,31 +782,157 @@ ], "node_retries": {}, "context_values": { - "internal.thread_id": "preflight_lint", + "internal.retry_count.toolchain": 0, + "outcome": "succeeded", + "thread.start.current_node": "toolchain", + "last_stage": "implement", + "current_node": "implement", + "graph.goal": "---\ntitle: Add Manual Run Retry\ntype: feat\nstatus: active\ndate: 2026-05-23\n---\n\n# Add Manual Run Retry\n\n## Summary\n\nAdd a **Retry** action for failed Fabro runs that creates and immediately starts a new run from the failed run's captured run definition. The new run is independent runtime state, records `retried_from: `, and leaves the source run unchanged.\n\nThis is a fresh run, not resume/fork/rewind. It should copy the source run's durable definition and settings, but not checkpoints, stage state, sandbox state, PR links, billing, questions, conclusions, or pending controls.\n\n## Key Changes\n\n- Add `retried_from` as a nullable public field on `Run`.\n - Store it on the new run only.\n - Do not add a reverse `retried_by` field in v1.\n - Preserve backward compatibility with old events by defaulting to `null`.\n\n- Add `POST /api/v1/runs/{id}/retry`.\n - Response: `201` with the newly created/queued `Run`.\n - Eligible source states: `failed` except `reason=cancelled`, and `dead`.\n - Reject active, succeeded, cancelled, archived, and missing runs with existing API error patterns.\n - The new run should use the current authenticated actor as `created_by`.\n - The new run should preserve the source run's current `parent_id`, title, labels, workflow graph/source, resolved settings, git context, manifest/definition blob refs, and `fork_source_ref` if present.\n\n- Implement retry using a workflow operation similar in shape to `fork`, but without replaying checkpoint/runtime events.\n - Create a new run store.\n - Append `run.created` with `retried_from`.\n - Append `run.submitted`.\n - Queue/start it through the same internal start path used by `POST /runs/{id}/start`.\n\n- Update OpenAPI and generated clients.\n - Edit `docs/public/api-reference/fabro-api.yaml`.\n - Regenerate Rust API types through `cargo build -p fabro-api`.\n - Regenerate TypeScript client in `lib/packages/fabro-api-client`.\n\n- Update the web UI.\n - Add `Retry` to the run action menu for eligible failed/dead runs.\n - Disable the action while pending.\n - On success, navigate to the new run page and refresh run/list caches.\n - Add a compact \"Retried from\" link in the run summary panel when `retried_from` is present.\n - Add demo-mode support or hide the action in demo mode so the button never navigates to a missing demo run.\n\n## Test Plan\n\n- Rust workflow/store tests:\n - `run.created` serializes/deserializes `retried_from`.\n - Old `run.created` events project with `retried_from = None`.\n - Retry creates a new run with a different ID, copied durable definition, no runtime state, and `retried_from` set.\n - Retry preserves current `parent_id`, title, labels, git context, settings, and `fork_source_ref`.\n - Retry rejects succeeded, active, cancelled, and archived source runs.\n\n- Rust server/API tests:\n - `POST /runs/{id}/retry` on a failed run returns `201`, a new run ID, `retried_from`, and queued/started lifecycle state.\n - Source run remains unchanged.\n - `404` for unknown run.\n - `409` for non-retryable status.\n - Generated Rust API compiles against the updated OpenAPI contract.\n\n- Web tests:\n - `canRetry` returns true for failed/dead, false for cancelled/succeeded/active/archived.\n - Action menu renders `Retry` only when eligible.\n - Successful retry calls the generated client and navigates to `/runs/:newId`.\n - Retry errors show a useful toast/message.\n - Run summary panel renders the `Retried from` link when present.\n - Typecheck passes with regenerated client types.\n\n## Assumptions\n\n- V1 does not add a CLI `fabro retry` command.\n- V1 does not add automatic retry attempts, retry counts, or idempotency keys.\n- Multiple manual clicks after the first request completes may create multiple retry runs.\n- \"Same settings\" means the source run's captured durable definition/settings, not latest local files from the user's machine.\n- Cancelled runs are excluded because cancellation is user intent, not execution failure.\n", + "internal.retry_count.implement": 0, + "internal.fidelity": "compact", + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "internal.node_visit_count": 1, + "internal.retry_count.preflight_compile": 0, + "failure_class": "", + "internal.retry_count.preflight_lint": 0, + "internal.work_dir": "/home/daytona/workspace/fabro", + "thread.preflight_lint.current_node": "implement", + "failure_signature": "", + "response.implement": "Implemented manual run retry end-to-end.\n\nSummary:\n- Added `retried_from` to:\n - `Run`\n - `RunProjection`\n - `run.created` event props\n - OpenAPI `Run` / `RunProjection`\n - generated TS API client models\n- Added workflow retry operation:\n - Creates a fresh run store\n - Copies durable definition/settings/source/git/labels/title/current parent/fork source/blob refs\n - Sets new `created_by` via provenance\n - Sets `retried_from`\n - Does not copy checkpoints/runtime/sandbox runtime/PR/conclusion/questions/pending controls\n - Rejects succeeded, active, cancelled, archived, and missing runs\n- Added `POST /api/v1/runs/{id}/retry`:\n - Returns `201` with the new queued `Run`\n - Queues via the same internal start path used by `POST /runs/{id}/start`\n - Leaves source run unchanged\n- Updated web UI:\n - Added `Retry` action for eligible failed/dead runs, hidden in demo mode\n - Disables while pending\n - Calls generated client, refreshes caches, and navigates to the new run on success\n - Shows retry errors via toast\n - Adds compact `Retried from` link in run summary panel\n- Regenerated Rust API and TypeScript Axios client.\n\nValidation run:\n- `cargo build -q -p fabro-api`\n- `cargo check -q --workspace`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings`\n- `cargo test -q -p fabro-api`\n- `cargo test -q -p fabro-store legacy_run_created_projects_retried_from_none --lib`\n- `cargo test -q -p fabro-workflow retry_ --lib`\n- `cargo test -q -p fabro-server retry_ --lib`\n- `cd apps/fabro-web && bun run typecheck`\n- `cd apps/fabro-web && bun test app/lib/run-actions.test.ts app/components/run-summary-panel.test.tsx app/routes/run-detail.test.ts`\n\nNote: I also attempted the full `cd apps/fabro-web && bun test`; it still reports unrelated test-isolation failures around shared `generatedAxios.defaults.adapter` state plus an existing run-detail dock assertion, while the focused retry/UI tests above pass.", "thread.preflight_compile.current_node": "preflight_lint", + "thread.toolchain.current_node": "preflight_compile", + "internal.run_id": "01KSA4H7JHBRPXZM3XTJ4SD9QA", + "graph.rankdir": "LR", + "last_response": "Implemented manual run retry end-to-end.\n\nSummary:\n- Added `retried_from` to:\n - `Run`\n - `RunProjection`\n - `run.created` event props\n - OpenAPI `Run` / `RunProjection`\n - generated TS API clien", + "internal.thread_id": "preflight_lint", + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "internal.retry_count.start": 0 + }, + "node_outcomes": { + "preflight_compile": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo check -q --workspace 2>&1", + "usage": null + }, + "toolchain": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/fc14b2ba2d770e5cd3169df7a29525c962adfc4cfa3097b9098c63ebd61a748c" + }, + "notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", + "usage": null + }, + "preflight_lint": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", + "usage": null + }, + "start": { + "status": "succeeded", + "usage": null + }, + "implement": { + "status": "succeeded", + "context_updates": { + "last_stage": "implement", + "response.implement": "Implemented manual run retry end-to-end.\n\nSummary:\n- Added `retried_from` to:\n - `Run`\n - `RunProjection`\n - `run.created` event props\n - OpenAPI `Run` / `RunProjection`\n - generated TS API client models\n- Added workflow retry operation:\n - Creates a fresh run store\n - Copies durable definition/settings/source/git/labels/title/current parent/fork source/blob refs\n - Sets new `created_by` via provenance\n - Sets `retried_from`\n - Does not copy checkpoints/runtime/sandbox runtime/PR/conclusion/questions/pending controls\n - Rejects succeeded, active, cancelled, archived, and missing runs\n- Added `POST /api/v1/runs/{id}/retry`:\n - Returns `201` with the new queued `Run`\n - Queues via the same internal start path used by `POST /runs/{id}/start`\n - Leaves source run unchanged\n- Updated web UI:\n - Added `Retry` action for eligible failed/dead runs, hidden in demo mode\n - Disables while pending\n - Calls generated client, refreshes caches, and navigates to the new run on success\n - Shows retry errors via toast\n - Adds compact `Retried from` link in run summary panel\n- Regenerated Rust API and TypeScript Axios client.\n\nValidation run:\n- `cargo build -q -p fabro-api`\n- `cargo check -q --workspace`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings`\n- `cargo test -q -p fabro-api`\n- `cargo test -q -p fabro-store legacy_run_created_projects_retried_from_none --lib`\n- `cargo test -q -p fabro-workflow retry_ --lib`\n- `cargo test -q -p fabro-server retry_ --lib`\n- `cd apps/fabro-web && bun run typecheck`\n- `cd apps/fabro-web && bun test app/lib/run-actions.test.ts app/components/run-summary-panel.test.tsx app/routes/run-detail.test.ts`\n\nNote: I also attempted the full `cd apps/fabro-web && bun test`; it still reports unrelated test-isolation failures around shared `generatedAxios.defaults.adapter` state plus an existing run-detail dock assertion, while the focused retry/UI tests above pass.", + "last_response": "Implemented manual run retry end-to-end.\n\nSummary:\n- Added `retried_from` to:\n - `Run`\n - `RunProjection`\n - `run.created` event props\n - OpenAPI `Run` / `RunProjection`\n - generated TS API clien" + }, + "notes": "Stage completed: implement", + "usage": { + "input": { + "usage": { + "model": { + "provider": "openai", + "model_id": "gpt-5.5" + }, + "tokens": { + "input_tokens": 463085, + "output_tokens": 38427, + "reasoning_tokens": 17656, + "cache_read_tokens": 64124928, + "cache_write_tokens": 0 + } + }, + "facts": { + "algorithm": "openai" + } + }, + "total_usd_micros": 36060379 + }, + "files_touched": [ + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/operations/retry.rs" + ] + } + }, + "next_node_id": "simplify_opus", + "git_commit_sha": "4aef10e4365662fc85fb7a66c4b36cd43702b3c5", + "node_visits": { + "preflight_compile": 1, + "toolchain": 1, + "preflight_lint": 1, + "start": 1, + "implement": 1 + } + }, + "diff": { + "patch": "diff --git a/apps/fabro-web/app/components/run-summary-panel.test.tsx b/apps/fabro-web/app/components/run-summary-panel.test.tsx\nindex 05cabf2d3..881026730 100644\n--- a/apps/fabro-web/app/components/run-summary-panel.test.tsx\n+++ b/apps/fabro-web/app/components/run-summary-panel.test.tsx\n@@ -1,5 +1,6 @@\n import { describe, expect, test } from \"bun:test\";\n import TestRenderer, { act } from \"react-test-renderer\";\n+import { MemoryRouter } from \"react-router\";\n \n import {\n RunSummaryPanelView,\n@@ -138,6 +139,29 @@ describe(\"RunSummaryPanelView\", () => {\n expect(instanceText(cellAfterLabel(tree, \"Artifacts\"))).toBe(\"3\");\n });\n \n+ test(\"renders Retried from link when present\", () => {\n+ let tree: TestRenderer.ReactTestRenderer | undefined;\n+ act(() => {\n+ tree = TestRenderer.create(\n+ \n+ \n+ ,\n+ );\n+ });\n+ const cell = cellAfterLabel(tree!, \"Retried from\");\n+ const link = cell.find((node) => node.type === \"a\");\n+ expect(link.props.href).toBe(\"/runs/01KRETRYFROMRUNID\");\n+ expect(instanceText(link)).toBe(\"01KRETRY\");\n+ });\n+\n test(\"renders user actor with login initial\", () => {\n const tree = render({\n run: makeRun({\ndiff --git a/apps/fabro-web/app/components/run-summary-panel.tsx b/apps/fabro-web/app/components/run-summary-panel.tsx\nindex c5004105f..30797329d 100644\n--- a/apps/fabro-web/app/components/run-summary-panel.tsx\n+++ b/apps/fabro-web/app/components/run-summary-panel.tsx\n@@ -13,6 +13,7 @@ import type {\n SandboxResources,\n SandboxState,\n } from \"@qltysh/fabro-api-client\";\n+import { Link } from \"react-router\";\n \n import {\n formatBytesAsMemory,\n@@ -223,6 +224,17 @@ export function RunSummaryPanelView({\n \n )}\n \n+\n+ {run?.retried_from && (\n+ \n+ \n+ {run.retried_from.slice(0, 8)}\n+ \n+ \n+ )}\n \n \n );\ndiff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts\nindex 8cbfdf661..2d6a750a2 100644\n--- a/apps/fabro-web/app/data/runs.test.ts\n+++ b/apps/fabro-web/app/data/runs.test.ts\n@@ -48,6 +48,7 @@ function makeRun(overrides: Partial = {}): Run {\n pull_request: null,\n current_question: null,\n superseded_by: null,\n+ retried_from: null,\n links: { web: null },\n ...overrides,\n };\n@@ -180,4 +181,4 @@ describe(\"columnForStatus\", () => {\n test(\"returns null for lifecycle states that do not map to a board column\", () => {\n expect(columnForStatus(\"removing\")).toBeNull();\n });\n-});\n\\ No newline at end of file\n+});\ndiff --git a/apps/fabro-web/app/lib/mutations.ts b/apps/fabro-web/app/lib/mutations.ts\nindex 889245bba..1c7ad0ef5 100644\n--- a/apps/fabro-web/app/lib/mutations.ts\n+++ b/apps/fabro-web/app/lib/mutations.ts\n@@ -21,6 +21,7 @@ import {\n archiveRun,\n cancelRun,\n isLifecycleActionError,\n+ retryRun,\n unarchiveRun,\n } from \"./run-actions\";\n \n@@ -47,6 +48,18 @@ export type LifecycleMutationResult =\n error: LifecycleActionError | null;\n };\n \n+export type RetryMutationResult =\n+ | {\n+ intent: \"retry\";\n+ ok: true;\n+ run: Run;\n+ }\n+ | {\n+ intent: \"retry\";\n+ ok: false;\n+ error: LifecycleActionError | null;\n+ };\n+\n export function usePreviewRun(id: string | undefined) {\n return useSWRMutation(\n id ? queryKeys.runs.preview(id) : null,\n@@ -71,6 +84,38 @@ export function useUnarchiveRun(id: string | undefined) {\n return useLifecycleMutation(id, \"unarchive\", unarchiveRun);\n }\n \n+export function useRetryRun(id: string | undefined) {\n+ const { mutate } = useSWRConfig();\n+ return useSWRMutation(\n+ id ? queryKeys.runs.retry(id) : null,\n+ async (): Promise => {\n+ if (!id) {\n+ return { intent: \"retry\", ok: false, error: null };\n+ }\n+ try {\n+ return { intent: \"retry\", ok: true, run: await retryRun(id) };\n+ } catch (error) {\n+ return {\n+ intent: \"retry\",\n+ ok: false,\n+ error: isLifecycleActionError(error) ? error : null,\n+ };\n+ }\n+ },\n+ {\n+ onSuccess: (result) => {\n+ if (!id || !result.ok) return;\n+ void mutate(queryKeys.runs.detail(id));\n+ void mutate(queryKeys.runs.detail(result.run.id), result.run, { revalidate: false });\n+ if (result.run.parent_id) {\n+ void mutate(queryKeys.runs.children(result.run.parent_id));\n+ }\n+ mutateBoardRunCaches(mutate);\n+ },\n+ },\n+ );\n+}\n+\n function useLifecycleMutation(\n id: string | undefined,\n intent: LifecycleAction,\ndiff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts\nindex c0d323e82..6034d9884 100644\n--- a/apps/fabro-web/app/lib/query-keys.ts\n+++ b/apps/fabro-web/app/lib/query-keys.ts\n@@ -77,6 +77,7 @@ export const queryKeys = {\n pullRequest: (id: string) => [\"runs\", \"pull-request\", id] as const,\n preview: (id: string) => [\"runs\", \"preview\", id] as const,\n cancel: (id: string) => [\"runs\", \"cancel\", id] as const,\n+ retry: (id: string) => [\"runs\", \"retry\", id] as const,\n archive: (id: string) => [\"runs\", \"archive\", id] as const,\n unarchive: (id: string) => [\"runs\", \"unarchive\", id] as const,\n updateTitle: (id: string) => [\"runs\", \"update-title\", id] as const,\ndiff --git a/apps/fabro-web/app/lib/run-actions.test.ts b/apps/fabro-web/app/lib/run-actions.test.ts\nindex 8a8fb9d9d..5e61a9587 100644\n--- a/apps/fabro-web/app/lib/run-actions.test.ts\n+++ b/apps/fabro-web/app/lib/run-actions.test.ts\n@@ -6,10 +6,12 @@ import {\n archiveRun,\n canArchive,\n canCancel,\n+ canRetry,\n canUnarchive,\n cancelRun,\n isTerminalCancelledRun,\n mapError,\n+ retryRun,\n unarchiveRun,\n } from \"./run-actions\";\n import { generatedAxios } from \"./api-client\";\n@@ -55,6 +57,7 @@ function makeRun(status: RunStatus, archived = false): Run {\n pull_request: null,\n current_question: null,\n superseded_by: null,\n+ retried_from: null,\n links: { web: null },\n };\n }\n@@ -135,6 +138,22 @@ describe(\"run lifecycle actions\", () => {\n expect(result.lifecycle.archived).toBe(false);\n });\n \n+ test(\"retryRun parses a 201 response\", async () => {\n+ stubGeneratedAxiosOnce({\n+ status: 201,\n+ body: {\n+ ...makeRun({ kind: \"queued\" }),\n+ id: \"run-2\",\n+ retried_from: \"run-1\",\n+ },\n+ });\n+\n+ const result = await retryRun(\"run-1\");\n+ expect(result.id).toBe(\"run-2\");\n+ expect(result.retried_from).toBe(\"run-1\");\n+ expect(result.lifecycle.status.kind).toBe(\"queued\");\n+ });\n+\n test(\"404 and 409 preserve the parsed error envelope\", async () => {\n stubGeneratedAxiosOnce({\n status: 404,\n@@ -196,6 +215,15 @@ describe(\"run lifecycle actions\", () => {\n expect(canUnarchive(\"failed\")).toBe(false);\n });\n \n+ test(\"canRetry allows failed and dead runs except cancelled or archived runs\", () => {\n+ expect(canRetry(makeRun({ kind: \"failed\", reason: \"workflow_error\" }))).toBe(true);\n+ expect(canRetry(makeRun({ kind: \"dead\" }))).toBe(true);\n+ expect(canRetry(makeRun({ kind: \"failed\", reason: \"cancelled\" }))).toBe(false);\n+ expect(canRetry(makeRun({ kind: \"succeeded\", reason: \"completed\" }))).toBe(false);\n+ expect(canRetry(makeRun({ kind: \"running\" }))).toBe(false);\n+ expect(canRetry(makeRun({ kind: \"failed\", reason: \"workflow_error\" }, true))).toBe(false);\n+ });\n+\n test(\"isTerminalCancelledRun distinguishes immediate cancel success from in-flight cancellation\", () => {\n expect(\n isTerminalCancelledRun(makeRun({ kind: \"failed\", reason: \"cancelled\" })),\ndiff --git a/apps/fabro-web/app/lib/run-actions.ts b/apps/fabro-web/app/lib/run-actions.ts\nindex ae3126d3a..cf8468398 100644\n--- a/apps/fabro-web/app/lib/run-actions.ts\n+++ b/apps/fabro-web/app/lib/run-actions.ts\n@@ -43,6 +43,14 @@ export async function unarchiveRun(id: string, request?: Request): Promise\n return runLifecycleAction(id, \"unarchive\", request);\n }\n \n+export async function retryRun(id: string, request?: Request): Promise {\n+ try {\n+ return await apiData(() => runsApi.retryRun(id, requestSignalOptions(request)));\n+ } catch (error) {\n+ throw lifecycleActionErrorFromError(error);\n+ }\n+}\n+\n export async function deleteRun(id: string, request?: Request): Promise {\n try {\n await apiResponse(() => runsApi.deleteRun(id, undefined, requestSignalOptions(request)));\n@@ -64,6 +72,13 @@ export function canUnarchive(status: string | null | undefined): boolean {\n return status === \"archived\";\n }\n \n+export function canRetry(run: Pick | null | undefined): boolean {\n+ if (!run || run.lifecycle.archived) return false;\n+ const status = run.lifecycle.status;\n+ if (status.kind === \"dead\") return true;\n+ return status.kind === \"failed\" && status.reason !== \"cancelled\";\n+}\n+\n export function canDelete(status: string | null | undefined): boolean {\n return status === \"archived\";\n }\n@@ -84,6 +99,20 @@ export function deleteErrorMessage(error: unknown): string {\n return \"Couldn't delete the run right now. Try again.\";\n }\n \n+export function retryErrorMessage(error: unknown): string {\n+ if (isLifecycleActionError(error)) {\n+ if (error.status === 404) {\n+ return \"This run no longer exists.\";\n+ }\n+ if (error.status === 409) {\n+ return \"This run can no longer be retried.\";\n+ }\n+ const detail = error.errors[0]?.detail?.trim();\n+ if (detail) return detail;\n+ }\n+ return \"Couldn't retry the run right now. Try again.\";\n+}\n+\n export function mapError(error: unknown, action: LifecycleAction): string {\n if (isLifecycleActionError(error)) {\n if (error.status === 404) {\ndiff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts\nindex a68b4a678..3c55988ba 100644\n--- a/apps/fabro-web/app/routes/run-detail.test.ts\n+++ b/apps/fabro-web/app/routes/run-detail.test.ts\n@@ -53,6 +53,7 @@ const {\n default: RunDetail,\n focusSteerAfterMenuClose,\n handleLifecycleToastResult,\n+ handleRetryResult,\n lifecycleActionVisibility,\n } = await import(\"./run-detail\");\n mock.restore();\n@@ -111,6 +112,7 @@ function makeRunSummary(\n pull_request: pullRequest,\n current_question: null,\n superseded_by: null,\n+ retried_from: null,\n links: { web: null },\n };\n }\n@@ -382,7 +384,7 @@ describe(\"RunDetail full-height child routes\", () => {\n const outletWrappers = renderer.root.findAll(\n (node) =>\n node.type === \"div\" &&\n- hasClasses(node.props.className, [\"pt-3\", \"min-h-0\", \"flex-1\"]),\n+ hasClasses(node.props.className, [\"pt-3.5\", \"min-h-0\", \"flex-1\"]),\n );\n expect(outletWrappers).toHaveLength(1);\n });\n@@ -401,6 +403,50 @@ describe(\"RunDetail full-height child routes\", () => {\n expect(badges.map((badge) => badge.children.join(\"\"))).toContain(\"7\");\n });\n \n+ test(\"successful retry result navigates to the new run once\", () => {\n+ const pushed: Array<{ message: string; tone?: string }> = [];\n+ const navigated: string[] = [];\n+ const result: RetryMutationResult = {\n+ intent: \"retry\",\n+ ok: true,\n+ run: {\n+ ...makeRunSummary(\"queued\"),\n+ id: \"run_retry\",\n+ retried_from: \"run_1\",\n+ },\n+ };\n+\n+ const next = handleRetryResult(\n+ result,\n+ null,\n+ {\n+ push: (toast) => {\n+ pushed.push(toast);\n+ return \"toast-1\";\n+ },\n+ dismiss: () => undefined,\n+ },\n+ (path) => navigated.push(path),\n+ );\n+ const replay = handleRetryResult(\n+ result,\n+ next,\n+ {\n+ push: (toast) => {\n+ pushed.push(toast);\n+ return \"toast-2\";\n+ },\n+ dismiss: () => undefined,\n+ },\n+ (path) => navigated.push(path),\n+ );\n+\n+ expect(next).toBe(result);\n+ expect(replay).toBe(result);\n+ expect(pushed).toEqual([{ message: \"Retry started.\" }]);\n+ expect(navigated).toEqual([\"/runs/run_retry\"]);\n+ });\n+\n test(\"shows the Sandbox tab when the run has a sandbox\", async () => {\n currentRunState = { sandbox: { provider: \"docker\", id: \"container-1\" } };\n const renderer = await renderRunDetail({\n@@ -541,10 +587,10 @@ describe(\"RunDetail full-height child routes\", () => {\n (node) =>\n node.type === \"div\" &&\n hasClasses(node.props.className, [\n- \"pt-3\",\n+ \"pt-3.5\",\n \"pb-[var(--fabro-interview-dock-clearance)]\",\n ]),\n );\n expect(outletWrappers).toHaveLength(1);\n });\n-});\n\\ No newline at end of file\n+});\ndiff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx\nindex 91e912ea9..a9922a074 100644\n--- a/apps/fabro-web/app/routes/run-detail.tsx\n+++ b/apps/fabro-web/app/routes/run-detail.tsx\n@@ -59,9 +59,11 @@ import {\n useCancelRun,\n useInterruptRun,\n usePreviewRun,\n+ useRetryRun,\n useUnarchiveRun,\n type LifecycleMutationResult,\n type PreviewMutationResult,\n+ type RetryMutationResult,\n } from \"../lib/mutations\";\n import { formatAbsoluteTs, formatRelativeTime } from \"../lib/format\";\n import { queryKeys } from \"../lib/query-keys\";\n@@ -72,11 +74,13 @@ import {\n canArchive,\n canCancel,\n canDelete,\n+ canRetry,\n canUnarchive,\n deleteErrorMessage,\n deleteRun,\n isTerminalCancelledRun,\n mapError,\n+ retryErrorMessage,\n type LifecycleAction,\n type LifecycleActionError,\n } from \"../lib/run-actions\";\n@@ -382,6 +386,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {\n const cancelMutation = useCancelRun(params.id);\n const archiveMutation = useArchiveRun(params.id);\n const unarchiveMutation = useUnarchiveRun(params.id);\n+ const retryMutation = useRetryRun(params.id);\n const interruptMutation = useInterruptRun(params.id);\n const navigate = useNavigate();\n const { mutate } = useSWRConfig();\n@@ -399,6 +404,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {\n })\n .filter((t) => (!t.demoOnly || demoMode) && (!t.requiresSandbox || hasSandbox));\n const lifecycleToastStateRef = useRef(INITIAL_LIFECYCLE_TOAST_STATE);\n+ const lastRetryResultRef = useRef(null);\n const steerBarRef = useRef(null);\n const now = useTickingNow(30_000);\n const fullHeight = matches.some(\n@@ -448,6 +454,15 @@ export default function RunDetail({ params }: { params: { id: string } }) {\n );\n }, [dismiss, push, unarchiveMutation.data]);\n \n+ useEffect(() => {\n+ lastRetryResultRef.current = handleRetryResult(\n+ retryMutation.data,\n+ lastRetryResultRef.current,\n+ { push, dismiss },\n+ navigate,\n+ );\n+ }, [dismiss, navigate, push, retryMutation.data]);\n+\n if (runQuery.isLoading && !run) {\n return
;\n }\n@@ -495,6 +510,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {\n const cancelPending = cancelMutation.isMutating;\n const archivePending = archiveMutation.isMutating;\n const unarchivePending = unarchiveMutation.isMutating;\n+ const retryPending = retryMutation.isMutating;\n const handleConfirmDelete = async () => {\n setDeletePending(true);\n try {\n@@ -630,6 +646,9 @@ export default function RunDetail({ params }: { params: { id: string } }) {\n canArchive={visibility.showArchive}\n archivePending={archivePending}\n onArchive={() => void archiveMutation.trigger()}\n+ canRetry={!demoMode && canRetry(summary)}\n+ retryPending={retryPending}\n+ onRetry={() => void retryMutation.trigger()}\n canUnarchive={visibility.showUnarchive}\n unarchivePending={unarchivePending}\n onUnarchive={() => void unarchiveMutation.trigger()}\n@@ -833,6 +852,22 @@ export function handleLifecycleToastResult(\n return { ...nextState, activeArchiveToastId: null };\n }\n \n+export function handleRetryResult(\n+ result: RetryMutationResult | undefined,\n+ lastProcessed: RetryMutationResult | null,\n+ toastApi: ToastApi,\n+ navigate: (path: string) => void,\n+): RetryMutationResult | null {\n+ if (!result || lastProcessed === result) return lastProcessed;\n+ if (result.ok === true) {\n+ toastApi.push({ message: \"Retry started.\" });\n+ navigate(`/runs/${result.run.id}`);\n+ } else {\n+ toastApi.push({ message: retryErrorMessage(result.error), tone: \"error\" });\n+ }\n+ return result;\n+}\n+\n function ConnectMenu() {\n return (\n \n@@ -872,6 +907,9 @@ interface ActionsMenuProps {\n canArchive: boolean;\n archivePending: boolean;\n onArchive: () => void;\n+ canRetry: boolean;\n+ retryPending: boolean;\n+ onRetry: () => void;\n canUnarchive: boolean;\n unarchivePending: boolean;\n onUnarchive: () => void;\n@@ -889,6 +927,7 @@ function ActionsMenu(props: ActionsMenuProps) {\n canFocusSteer, onFocusSteer,\n canPreview, previewPending, onPreview,\n canArchive, archivePending, onArchive,\n+ canRetry, retryPending, onRetry,\n canUnarchive, unarchivePending, onUnarchive,\n canDelete, deletePending, onDelete,\n canCancel, cancelPending, onCancel,\n@@ -896,11 +935,11 @@ function ActionsMenu(props: ActionsMenuProps) {\n \n const hasOps =\n canPreview || canSendInterrupt || canFocusSteer;\n- const hasLifecycle = canArchive || canUnarchive;\n+ const hasLifecycle = canRetry || canArchive || canUnarchive;\n const hasDestructive = canCancel || canDelete;\n const hasAny = hasOps || hasLifecycle || hasDestructive;\n const anyPending =\n- previewPending || archivePending || unarchivePending || deletePending || cancelPending || interruptPending;\n+ previewPending || retryPending || archivePending || unarchivePending || deletePending || cancelPending || interruptPending;\n const separators = actionMenuSeparatorVisibility({ hasLifecycle, hasDestructive });\n \n if (!hasAny) return null;\n@@ -952,6 +991,18 @@ function ActionsMenu(props: ActionsMenuProps) {\n {separators.afterOperations && (\n
\n )}\n+ {canRetry && (\n+ \n+ \n+ {retryPending ? \"Retrying…\" : \"Retry\"}\n+ \n+ \n+ )}\n {canArchive && (\n \n ({\n pull_request: null,\n current_question: null,\n superseded_by: null,\n+ retried_from: null,\n links: { web: null },\n },\n }),\ndiff --git a/apps/fabro-web/app/routes/runs.test.tsx b/apps/fabro-web/app/routes/runs.test.tsx\nindex aab1c7abe..61abb3384 100644\n--- a/apps/fabro-web/app/routes/runs.test.tsx\n+++ b/apps/fabro-web/app/routes/runs.test.tsx\n@@ -53,6 +53,7 @@ function boardRun(id: string, column: BoardColumn, questionText?: string): Run {\n pull_request: null,\n current_question: questionText ? { text: questionText } : null,\n superseded_by: null,\n+ retried_from: null,\n links: { web: null },\n };\n }\ndiff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml\nindex 44bfe394c..690daad0a 100644\n--- a/docs/public/api-reference/fabro-api.yaml\n+++ b/docs/public/api-reference/fabro-api.yaml\n@@ -1855,6 +1855,44 @@ paths:\n schema:\n $ref: \"#/components/schemas/ErrorResponse\"\n \n+ /api/v1/runs/{id}/retry:\n+ post:\n+ operationId: retryRun\n+ tags: [Runs]\n+ summary: Retry Run\n+ description: >\n+ Creates a fresh run from the failed or dead source run's captured\n+ durable definition, records `retried_from` on the new run, and queues it\n+ for execution. The source run is left unchanged. Cancelled, active,\n+ succeeded, and archived runs are not retryable.\n+ parameters:\n+ - $ref: \"#/components/parameters/RunId\"\n+ responses:\n+ \"201\":\n+ description: New retry run created and queued\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/Run\"\n+ \"404\":\n+ description: Run not found\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"409\":\n+ description: Source run is not retryable\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+\n /api/v1/runs/{id}/pause:\n post:\n operationId: pauseRun\n@@ -8027,6 +8065,9 @@ components:\n - type: \"null\"\n superseded_by:\n type: [\"string\", \"null\"]\n+ retried_from:\n+ type: [\"string\", \"null\"]\n+ description: Source run ID when this run was created by manual retry.\n pending_interviews:\n type: object\n additionalProperties:\n@@ -8139,6 +8180,7 @@ components:\n - pull_request\n - current_question\n - superseded_by\n+ - retried_from\n - links\n - children_count\n properties:\n@@ -8217,6 +8259,10 @@ components:\n - type: \"null\"\n superseded_by:\n type: [\"string\", \"null\"]\n+ description: Run ID that superseded this run via rewind, if any.\n+ retried_from:\n+ type: [\"string\", \"null\"]\n+ description: Source run ID when this run was created by manual retry.\n links:\n $ref: \"#/components/schemas/RunLinks\"\n \n@@ -11651,4 +11697,4 @@ components:\n login:\n type: string\n description: User's login identifier (e.g. GitHub username).\n- example: octocat\n\\ No newline at end of file\n+ example: octocat\ndiff --git a/lib/crates/fabro-api/tests/run_summary_round_trip.rs b/lib/crates/fabro-api/tests/run_summary_round_trip.rs\nindex d310dd7a5..7b422e1b6 100644\n--- a/lib/crates/fabro-api/tests/run_summary_round_trip.rs\n+++ b/lib/crates/fabro-api/tests/run_summary_round_trip.rs\n@@ -7,7 +7,7 @@ use fabro_types::status::{RunStatus, SuccessReason};\n use fabro_types::{\n AskFabro, AskFabroUnavailableReason, DiffSummary, PullRequestLink, RepositoryProvider,\n RepositoryRef, Run, RunBillingSummary, RunId, RunLifecycle, RunLinks, RunOrigin, RunTimestamps,\n- RunTiming, WorkflowRef,\n+ RunTiming, WorkflowRef, fixtures,\n };\n use serde_json::json;\n \n@@ -85,6 +85,7 @@ fn run_summary_json_matches_openapi_shape() {\n }),\n current_question: None,\n superseded_by: None,\n+ retried_from: Some(fixtures::RUN_2),\n links: RunLinks { web: None },\n };\n \n@@ -162,6 +163,7 @@ fn run_summary_json_matches_openapi_shape() {\n },\n \"current_question\": null,\n \"superseded_by\": null,\n+ \"retried_from\": fixtures::RUN_2.to_string(),\n \"links\": {\n \"web\": null\n }\n@@ -238,6 +240,7 @@ fn run_summary_deserializes_when_optional_fields_are_absent() {\n assert_eq!(summary.billing, None);\n assert_eq!(summary.ask_fabro, AskFabro::default());\n assert_eq!(summary.superseded_by, None);\n+ assert_eq!(summary.retried_from, None);\n assert_eq!(summary.diff, None);\n assert_eq!(summary.pull_request, None);\n }\ndiff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs\nindex a71d9c748..5ad812525 100644\n--- a/lib/crates/fabro-server/src/demo/mod.rs\n+++ b/lib/crates/fabro-server/src/demo/mod.rs\n@@ -1155,6 +1155,7 @@ mod runs {\n pull_request: None,\n current_question: None,\n superseded_by: None,\n+ retried_from: None,\n links: RunLinks { web: None },\n }\n }\ndiff --git a/lib/crates/fabro-server/src/server/handler/events.rs b/lib/crates/fabro-server/src/server/handler/events.rs\nindex c34d07a25..d9533cbb9 100644\n--- a/lib/crates/fabro-server/src/server/handler/events.rs\n+++ b/lib/crates/fabro-server/src/server/handler/events.rs\n@@ -567,6 +567,7 @@ mod stage_events_tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\ndiff --git a/lib/crates/fabro-server/src/server/handler/lifecycle.rs b/lib/crates/fabro-server/src/server/handler/lifecycle.rs\nindex 8360dddf3..ee62c5736 100644\n--- a/lib/crates/fabro-server/src/server/handler/lifecycle.rs\n+++ b/lib/crates/fabro-server/src/server/handler/lifecycle.rs\n@@ -1,14 +1,15 @@\n use std::sync::Arc;\n \n use super::super::{\n- ApiError, AppState, FailureReason, ForkRequest, ForkResponse, IntoResponse, Json, Path,\n- Principal, RequireRunScopedOrRunTools, RequiredUser, Response, RewindRequest, RewindResponse,\n- Router, RunAnswerTransport, RunControlAction, RunExecutionMode, RunId, RunStatus,\n- StartRunRequest, State, StatusCode, Storage, TimelineEntryResponse, WORKER_CANCEL_GRACE,\n- WorkflowError, append_control_request, durable_run_status, get, load_pending_control,\n- managed_run, operations, parse_run_id_path, persist_cancelled_run_status, post,\n- reject_if_archived, sleep, update_live_run_from_event, workflow_event,\n+ ApiError, AppState, FailureReason, ForkRequest, ForkResponse, HeaderMap, IntoResponse, Json,\n+ Path, Principal, RequireRunScopedOrRunTools, RequiredUser, Response, RewindRequest,\n+ RewindResponse, Router, RunAnswerTransport, RunControlAction, RunExecutionMode, RunId,\n+ RunStatus, StartRunRequest, State, StatusCode, Storage, TimelineEntryResponse,\n+ WORKER_CANCEL_GRACE, WorkflowError, append_control_request, durable_run_status, get,\n+ load_pending_control, managed_run, operations, parse_run_id_path, persist_cancelled_run_status,\n+ post, reject_if_archived, sleep, update_live_run_from_event, workflow_event,\n };\n+use super::runs::run_provenance;\n \n pub(super) fn routes() -> Router> {\n Router::new()\n@@ -18,6 +19,7 @@ pub(super) fn routes() -> Router> {\n .route(\"/runs/{id}/unpause\", post(unpause_run))\n .route(\"/runs/{id}/archive\", post(archive_run))\n .route(\"/runs/{id}/rewind\", post(rewind_run))\n+ .route(\"/runs/{id}/retry\", post(retry_run))\n .route(\"/runs/{id}/fork\", post(fork_run))\n .route(\"/runs/{id}/timeline\", get(run_timeline))\n .route(\"/runs/{id}/unarchive\", post(unarchive_run))\n@@ -45,6 +47,13 @@ async fn start_run(\n }\n let resume = body.is_some_and(|Json(req)| req.resume);\n \n+ match queue_run_start(state.as_ref(), id, resume).await {\n+ Ok(()) => run_response(state.as_ref(), id, StatusCode::OK).await,\n+ Err(err) => err.into_response(),\n+ }\n+}\n+\n+async fn queue_run_start(state: &AppState, id: RunId, resume: bool) -> Result<(), ApiError> {\n {\n let runs = state.runs.lock().expect(\"runs lock poisoned\");\n if let Some(managed_run) = runs.get(&id) {\n@@ -56,37 +65,37 @@ async fn start_run(\n | RunStatus::Blocked { .. }\n | RunStatus::Paused { .. }\n ) {\n- return ApiError::new(\n+ return Err(ApiError::new(\n StatusCode::CONFLICT,\n if resume {\n \"an engine process is still running for this run — cannot resume\"\n } else {\n \"an engine process is still running for this run — cannot start\"\n },\n- )\n- .into_response();\n+ ));\n }\n }\n }\n \n let Ok(run_store) = state.store.open_run(&id).await else {\n- return ApiError::not_found(\"Run not found.\").into_response();\n+ return Err(ApiError::not_found(\"Run not found.\"));\n };\n let run_state = match run_store.state().await {\n Ok(state) => state,\n Err(err) => {\n- return ApiError::new(\n+ return Err(ApiError::new(\n StatusCode::INTERNAL_SERVER_ERROR,\n format!(\"Failed to load run state: {err}\"),\n- )\n- .into_response();\n+ ));\n }\n };\n \n if resume {\n if run_state.current_checkpoint().is_none() {\n- return ApiError::new(StatusCode::CONFLICT, \"no checkpoint to resume from\")\n- .into_response();\n+ return Err(ApiError::new(\n+ StatusCode::CONFLICT,\n+ \"no checkpoint to resume from\",\n+ ));\n }\n } else {\n let status = run_state.status;\n@@ -94,11 +103,10 @@ async fn start_run(\n status,\n RunStatus::Submitted | RunStatus::Queued | RunStatus::Starting\n ) {\n- return ApiError::new(\n+ return Err(ApiError::new(\n StatusCode::CONFLICT,\n format!(\"cannot start run: status is {status}, expected submitted\"),\n- )\n- .into_response();\n+ ));\n }\n }\n \n@@ -110,7 +118,10 @@ async fn start_run(\n if let Err(err) =\n workflow_event::append_event(&run_store, &id, &workflow_event::Event::RunQueued).await\n {\n- return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();\n+ return Err(ApiError::new(\n+ StatusCode::INTERNAL_SERVER_ERROR,\n+ err.to_string(),\n+ ));\n }\n \n {\n@@ -132,7 +143,7 @@ async fn start_run(\n }\n \n state.scheduler_notify.notify_one();\n- run_response(state.as_ref(), id, StatusCode::OK).await\n+ Ok(())\n }\n \n fn schedule_worker_kill(state: Arc, run_id: RunId, worker_pid: u32) {\n@@ -553,6 +564,36 @@ async fn fork_run(\n }\n }\n \n+async fn retry_run(\n+ RequiredUser(user): RequiredUser,\n+ State(state): State>,\n+ headers: HeaderMap,\n+ Path(id): Path,\n+) -> Response {\n+ let id = match parse_run_id_path(&id) {\n+ Ok(id) => id,\n+ Err(response) => return response,\n+ };\n+ let actor = Principal::User(user);\n+ let new_run_id = RunId::new();\n+ let input = operations::RetryRunInput {\n+ source_run_id: id,\n+ new_run_id: Some(new_run_id),\n+ provenance: Some(run_provenance(&headers, &actor)),\n+ web_url: state.run_web_url(&new_run_id),\n+ };\n+ match Box::pin(operations::retry_run(&state.store, &input)).await {\n+ Ok(outcome) => {\n+ let new_run_id = outcome.new_run_id;\n+ if let Err(err) = queue_run_start(state.as_ref(), new_run_id, false).await {\n+ return err.into_response();\n+ }\n+ run_response(state.as_ref(), new_run_id, StatusCode::CREATED).await\n+ }\n+ Err(err) => workflow_operation_error_response(err),\n+ }\n+}\n+\n async fn run_timeline(\n _auth: RequiredUser,\n State(state): State>,\ndiff --git a/lib/crates/fabro-server/src/server/handler/pair.rs b/lib/crates/fabro-server/src/server/handler/pair.rs\nindex 942325fe6..785950baa 100644\n--- a/lib/crates/fabro-server/src/server/handler/pair.rs\n+++ b/lib/crates/fabro-server/src/server/handler/pair.rs\n@@ -1060,6 +1060,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\ndiff --git a/lib/crates/fabro-server/src/server/handler/runs.rs b/lib/crates/fabro-server/src/server/handler/runs.rs\nindex 99b2b94df..e89ca13f4 100644\n--- a/lib/crates/fabro-server/src/server/handler/runs.rs\n+++ b/lib/crates/fabro-server/src/server/handler/runs.rs\n@@ -632,7 +632,7 @@ async fn create_run(\n .into_response()\n }\n \n-fn run_provenance(headers: &HeaderMap, subject: &Principal) -> RunProvenance {\n+pub(super) fn run_provenance(headers: &HeaderMap, subject: &Principal) -> RunProvenance {\n RunProvenance {\n server: Some(RunServerProvenance {\n version: FABRO_VERSION.to_string(),\ndiff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs\nindex a9ad98fd5..cf36d5476 100644\n--- a/lib/crates/fabro-server/src/server/tests.rs\n+++ b/lib/crates/fabro-server/src/server/tests.rs\n@@ -2684,6 +2684,7 @@ async fn append_default_run_created(run_store: &fabro_store::RunDatabase, run_id\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\n@@ -3032,6 +3033,7 @@ async fn list_run_stages_distinguishes_visits() {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n },\n@@ -3932,14 +3934,14 @@ async fn pr_test_app_with_completed_run(\n repo_origin_url: Option<&str>,\n ) -> (Arc, Router, RunId) {\n let (state, app, run_id) = pr_test_app(token, github_api_base_url);\n- create_completed_run_ready_for_pull_request(\n+ Box::pin(create_completed_run_ready_for_pull_request(\n &state,\n run_id,\n repo_origin_url,\n Some(\"main\"),\n Some(\"fabro/run/42\"),\n \"diff --git a/src/lib.rs b/src/lib.rs\\n+fn shipped() {}\\n\",\n- )\n+ ))\n .await;\n (state, app, run_id)\n }\n@@ -4032,6 +4034,7 @@ async fn create_completed_run_ready_for_pull_request(\n manifest_blob: None,\n git,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n },\n@@ -5690,14 +5693,14 @@ async fn create_run_pull_request_creates_and_persists_record() {\n .unwrap();\n let app = crate::test_support::build_test_router(Arc::clone(&state));\n let run_id = fixtures::RUN_1;\n- create_completed_run_ready_for_pull_request(\n+ Box::pin(create_completed_run_ready_for_pull_request(\n &state,\n run_id,\n Some(\"git@github.com:acme/widgets.git\"),\n Some(\"main\"),\n Some(\"fabro/run/42\"),\n \"diff --git a/src/lib.rs b/src/lib.rs\\n+fn shipped() {}\\n\",\n- )\n+ ))\n .await;\n \n let response = app\n@@ -5783,7 +5786,7 @@ async fn create_run_pull_request_returns_conflict_when_record_exists() {\n \n #[tokio::test]\n async fn create_run_pull_request_rejects_missing_repo_origin() {\n- let (_state, app, run_id) = pr_test_app_with_completed_run(None, None, None).await;\n+ let (_state, app, run_id) = Box::pin(pr_test_app_with_completed_run(None, None, None)).await;\n \n let response = app\n .oneshot(\n@@ -5809,9 +5812,12 @@ async fn create_run_pull_request_rejects_missing_repo_origin() {\n \n #[tokio::test]\n async fn create_run_pull_request_returns_service_unavailable_without_github_credentials() {\n- let (_state, app, run_id) =\n- pr_test_app_with_completed_run(None, None, Some(\"https://github.com/acme/widgets.git\"))\n- .await;\n+ let (_state, app, run_id) = Box::pin(pr_test_app_with_completed_run(\n+ None,\n+ None,\n+ Some(\"https://github.com/acme/widgets.git\"),\n+ ))\n+ .await;\n \n let response = app\n .oneshot(\n@@ -5837,11 +5843,11 @@ async fn create_run_pull_request_returns_service_unavailable_without_github_cred\n \n #[tokio::test]\n async fn create_run_pull_request_rejects_non_github_origin_url() {\n- let (_state, app, run_id) = pr_test_app_with_completed_run(\n+ let (_state, app, run_id) = Box::pin(pr_test_app_with_completed_run(\n Some(\"ghu_test\"),\n None,\n Some(\"https://gitlab.com/acme/widgets.git\"),\n- )\n+ ))\n .await;\n \n let response = app\n@@ -7844,6 +7850,133 @@ async fn start_run_conflict_when_not_submitted() {\n assert_status!(response, StatusCode::CONFLICT).await;\n }\n \n+#[tokio::test]\n+async fn retry_failed_run_creates_and_queues_new_run() {\n+ let state = test_app_state_with_isolated_storage();\n+ let app = crate::test_support::build_test_router(Arc::clone(&state));\n+ let source_run_id = RunId::new();\n+ create_durable_run_with_events(&state, source_run_id, &[\n+ workflow_event::Event::RunSubmitted {\n+ definition_blob: None,\n+ },\n+ workflow_event::Event::workflow_run_failed_from_error(\n+ &WorkflowError::engine(\"boom\"),\n+ fabro_types::RunTiming::wall_only(10),\n+ FailureReason::WorkflowError,\n+ None,\n+ None,\n+ None,\n+ None,\n+ ),\n+ ])\n+ .await;\n+ let source_events_before = state\n+ .store\n+ .open_run(&source_run_id)\n+ .await\n+ .unwrap()\n+ .list_events()\n+ .await\n+ .unwrap()\n+ .len();\n+\n+ let response = app\n+ .oneshot(\n+ Request::builder()\n+ .method(\"POST\")\n+ .uri(api(&format!(\"/runs/{source_run_id}/retry\")))\n+ .body(Body::empty())\n+ .unwrap(),\n+ )\n+ .await\n+ .unwrap();\n+ let body = response_json!(response, StatusCode::CREATED).await;\n+ let new_run_id = body[\"id\"].as_str().unwrap().parse::().unwrap();\n+\n+ assert_ne!(new_run_id, source_run_id);\n+ assert_eq!(body[\"retried_from\"], source_run_id.to_string());\n+ assert_eq!(body[\"created_by\"][\"kind\"], \"user\");\n+ assert_eq!(body[\"created_by\"][\"login\"], \"dev\");\n+ assert_eq!(run_json_status(&body)[\"kind\"], \"queued\");\n+\n+ let source_store = state.store.open_run(&source_run_id).await.unwrap();\n+ assert_eq!(\n+ source_store.list_events().await.unwrap().len(),\n+ source_events_before\n+ );\n+ assert_eq!(\n+ source_store.state().await.unwrap().status,\n+ RunStatus::Failed {\n+ reason: FailureReason::WorkflowError,\n+ }\n+ );\n+\n+ let new_state = state\n+ .store\n+ .open_run(&new_run_id)\n+ .await\n+ .unwrap()\n+ .state()\n+ .await\n+ .unwrap();\n+ assert_eq!(new_state.retried_from, Some(source_run_id));\n+ assert_eq!(new_state.status, RunStatus::Queued);\n+ assert!(new_state.checkpoints.is_empty());\n+}\n+\n+#[tokio::test]\n+async fn retry_missing_run_returns_not_found() {\n+ let state = test_app_state();\n+ let app = crate::test_support::build_test_router(state);\n+\n+ let response = app\n+ .oneshot(\n+ Request::builder()\n+ .method(\"POST\")\n+ .uri(api(&format!(\"/runs/{}/retry\", fixtures::RUN_64)))\n+ .body(Body::empty())\n+ .unwrap(),\n+ )\n+ .await\n+ .unwrap();\n+\n+ assert_status!(response, StatusCode::NOT_FOUND).await;\n+}\n+\n+#[tokio::test]\n+async fn retry_non_retryable_run_returns_conflict() {\n+ let state = test_app_state();\n+ let app = crate::test_support::build_test_router(Arc::clone(&state));\n+ let source_run_id = RunId::new();\n+ create_durable_run_with_events(&state, source_run_id, &[\n+ workflow_event::Event::WorkflowRunCompleted {\n+ timing: fabro_types::RunTiming::wall_only(10),\n+ artifact_count: 0,\n+ status: \"succeeded\".to_string(),\n+ reason: SuccessReason::Completed,\n+ total_usd_micros: None,\n+ final_git_commit_sha: None,\n+ final_patch: None,\n+ diff_summary: None,\n+ billing: None,\n+ },\n+ ])\n+ .await;\n+\n+ let response = app\n+ .oneshot(\n+ Request::builder()\n+ .method(\"POST\")\n+ .uri(api(&format!(\"/runs/{source_run_id}/retry\")))\n+ .body(Body::empty())\n+ .unwrap(),\n+ )\n+ .await\n+ .unwrap();\n+\n+ assert_status!(response, StatusCode::CONFLICT).await;\n+}\n+\n #[tokio::test]\n async fn cancel_run_succeeds() {\n let state = test_app_state();\n@@ -9042,6 +9175,7 @@ async fn delete_run_with_preserved_sandbox_returns_handoff() {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n },\n@@ -9109,6 +9243,7 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n },\ndiff --git a/lib/crates/fabro-server/tests/it/api/run_files.rs b/lib/crates/fabro-server/tests/it/api/run_files.rs\nindex 88df727c8..d13a6ef29 100644\n--- a/lib/crates/fabro-server/tests/it/api/run_files.rs\n+++ b/lib/crates/fabro-server/tests/it/api/run_files.rs\n@@ -72,6 +72,7 @@ async fn append_completed_run_with_final_patch(\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\ndiff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs\nindex 0da6e957c..93b66b7c7 100644\n--- a/lib/crates/fabro-store/src/run_state.rs\n+++ b/lib/crates/fabro-store/src/run_state.rs\n@@ -534,6 +534,7 @@ fn projection_from_created(event: &EventEnvelope) -> Result {\n \n let mut projection = RunProjection::new(title, spec, stored.ts);\n projection.parent_id = props.parent_id;\n+ projection.retried_from = props.retried_from;\n projection.web_url.clone_from(&props.web_url);\n projection.sandbox = Some(planned_sandbox(&projection.spec.settings.run.sandbox));\n Ok(projection)\n@@ -723,6 +724,7 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {\n pull_request: state.pull_request.clone(),\n current_question,\n superseded_by: state.superseded_by,\n+ retried_from: state.retried_from,\n links: RunLinks {\n web: state.web_url.clone(),\n },\n@@ -1051,6 +1053,28 @@ mod tests {\n state\n }\n \n+ #[test]\n+ fn legacy_run_created_projects_retried_from_none() {\n+ let event = test_raw_event(\n+ 1,\n+ \"run.created\",\n+ &json!({\n+ \"settings\": WorkflowSettings::default(),\n+ \"graph\": Graph::new(\"test\"),\n+ \"labels\": {},\n+ \"run_dir\": \"/tmp/run\"\n+ }),\n+ None,\n+ );\n+\n+ let projection = RunProjection::apply_events(&[event]).unwrap();\n+ assert_eq!(projection.retried_from, None);\n+ assert_eq!(\n+ build_summary(&projection, &fixtures::RUN_1).retried_from,\n+ None\n+ );\n+ }\n+\n fn test_raw_event(\n seq: u32,\n event: &str,\ndiff --git a/lib/crates/fabro-tool/src/common.rs b/lib/crates/fabro-tool/src/common.rs\nindex 9ed84e6db..ff7eb7b0d 100644\n--- a/lib/crates/fabro-tool/src/common.rs\n+++ b/lib/crates/fabro-tool/src/common.rs\n@@ -350,6 +350,7 @@ mod tests {\n pull_request: None,\n current_question: None,\n superseded_by: None,\n+ retried_from: None,\n links: RunLinks { web: None },\n };\n \ndiff --git a/lib/crates/fabro-tool/src/create.rs b/lib/crates/fabro-tool/src/create.rs\nindex 0d283fc06..24a61a8c7 100644\n--- a/lib/crates/fabro-tool/src/create.rs\n+++ b/lib/crates/fabro-tool/src/create.rs\n@@ -865,6 +865,7 @@ mod tests {\n pull_request: None,\n current_question: None,\n superseded_by: None,\n+ retried_from: None,\n links: RunLinks { web: None },\n }\n }\ndiff --git a/lib/crates/fabro-tool/src/search.rs b/lib/crates/fabro-tool/src/search.rs\nindex 6d61b524c..50449df21 100644\n--- a/lib/crates/fabro-tool/src/search.rs\n+++ b/lib/crates/fabro-tool/src/search.rs\n@@ -471,6 +471,7 @@ mod tests {\n pull_request: None,\n current_question: None,\n superseded_by: None,\n+ retried_from: None,\n links: RunLinks { web: None },\n }\n }\ndiff --git a/lib/crates/fabro-types/src/run_event/run.rs b/lib/crates/fabro-types/src/run_event/run.rs\nindex e7f72d68f..ce41482d5 100644\n--- a/lib/crates/fabro-types/src/run_event/run.rs\n+++ b/lib/crates/fabro-types/src/run_event/run.rs\n@@ -37,6 +37,8 @@ pub struct RunCreatedProps {\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub fork_source_ref: Option,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub retried_from: Option,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub parent_id: Option,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub web_url: Option,\ndiff --git a/lib/crates/fabro-types/src/run_projection.rs b/lib/crates/fabro-types/src/run_projection.rs\nindex 0dafd1da4..374b6834f 100644\n--- a/lib/crates/fabro-types/src/run_projection.rs\n+++ b/lib/crates/fabro-types/src/run_projection.rs\n@@ -32,6 +32,8 @@ pub struct RunProjection {\n pub sandbox: Option,\n pub pull_request: Option,\n pub superseded_by: Option,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub retried_from: Option,\n pub pending_interviews: BTreeMap,\n /// Projected todo / task lists, keyed by `list_id` (`openai_plan:`\n /// or `anthropic_tasks:`). Maintained by replaying\n@@ -184,6 +186,7 @@ impl RunProjection {\n sandbox: None,\n pull_request: None,\n superseded_by: None,\n+ retried_from: None,\n pending_interviews: BTreeMap::new(),\n todos_by_list: BTreeMap::new(),\n stages: HashMap::new(),\ndiff --git a/lib/crates/fabro-types/src/run_summary.rs b/lib/crates/fabro-types/src/run_summary.rs\nindex 897ae61c0..d82f1c209 100644\n--- a/lib/crates/fabro-types/src/run_summary.rs\n+++ b/lib/crates/fabro-types/src/run_summary.rs\n@@ -79,6 +79,8 @@ pub struct Run {\n pub current_question: Option,\n #[serde(default)]\n pub superseded_by: Option,\n+ #[serde(default)]\n+ pub retried_from: Option,\n pub links: RunLinks,\n }\n \ndiff --git a/lib/crates/fabro-types/tests/run_event_serde.rs b/lib/crates/fabro-types/tests/run_event_serde.rs\nindex 7eeb4e5ab..8f2df1972 100644\n--- a/lib/crates/fabro-types/tests/run_event_serde.rs\n+++ b/lib/crates/fabro-types/tests/run_event_serde.rs\n@@ -40,6 +40,7 @@ fn run_created_props_round_trip_templated_settings() {\n source_run_id: fixtures::RUN_2,\n checkpoint_sha: \"def456\".to_string(),\n }),\n+ retried_from: Some(fixtures::RUN_1),\n parent_id: Some(fixtures::RUN_2),\n web_url: Some(\"http://localhost:3000/runs/01JNQVR7M0EJ5GKAT2SC4ERS1Z\".to_string()),\n };\n@@ -59,6 +60,7 @@ fn run_created_props_round_trip_templated_settings() {\n json[\"web_url\"],\n \"http://localhost:3000/runs/01JNQVR7M0EJ5GKAT2SC4ERS1Z\"\n );\n+ assert_eq!(json[\"retried_from\"], fixtures::RUN_1.to_string());\n assert_eq!(json[\"parent_id\"], fixtures::RUN_2.to_string());\n \n let round_trip: RunCreatedProps =\n@@ -91,6 +93,7 @@ fn run_created_props_omits_web_url_when_absent() {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n };\n@@ -104,11 +107,31 @@ fn run_created_props_omits_web_url_when_absent() {\n json.get(\"parent_id\").is_none(),\n \"parent_id must be omitted when None, got {json}\"\n );\n+ assert!(\n+ json.get(\"retried_from\").is_none(),\n+ \"retried_from must be omitted when None, got {json}\"\n+ );\n \n let round_trip: RunCreatedProps =\n serde_json::from_value(json.clone()).expect(\"props should deserialize\");\n assert_eq!(round_trip.web_url, None);\n assert_eq!(round_trip.parent_id, None);\n+ assert_eq!(round_trip.retried_from, None);\n+}\n+\n+#[test]\n+fn run_created_props_defaults_retried_from_for_legacy_events() {\n+ let json = serde_json::json!({\n+ \"title\": null,\n+ \"settings\": WorkflowSettings::default(),\n+ \"graph\": Graph::new(\"ship\"),\n+ \"labels\": {},\n+ \"run_dir\": \"/tmp/run\"\n+ });\n+\n+ let props: RunCreatedProps =\n+ serde_json::from_value(json).expect(\"legacy props should deserialize\");\n+ assert_eq!(props.retried_from, None);\n }\n \n #[test]\ndiff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs\nindex 3fbb3adf2..49c06b5d1 100644\n--- a/lib/crates/fabro-workflow/src/event/convert.rs\n+++ b/lib/crates/fabro-workflow/src/event/convert.rs\n@@ -39,6 +39,7 @@ fn event_body_from_event(event: &Event) -> EventBody {\n manifest_blob,\n git,\n fork_source_ref,\n+ retried_from,\n parent_id,\n web_url,\n ..\n@@ -58,6 +59,7 @@ fn event_body_from_event(event: &Event) -> EventBody {\n manifest_blob: *manifest_blob,\n git: git.clone(),\n fork_source_ref: fork_source_ref.clone(),\n+ retried_from: *retried_from,\n parent_id: *parent_id,\n web_url: web_url.clone(),\n }),\n@@ -2322,6 +2324,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n });\ndiff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs\nindex daf48556e..be31e8ad8 100644\n--- a/lib/crates/fabro-workflow/src/event/events.rs\n+++ b/lib/crates/fabro-workflow/src/event/events.rs\n@@ -46,6 +46,8 @@ pub enum Event {\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n fork_source_ref: Option,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ retried_from: Option,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n parent_id: Option,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n web_url: Option,\ndiff --git a/lib/crates/fabro-workflow/src/event/sink.rs b/lib/crates/fabro-workflow/src/event/sink.rs\nindex bf9ff0714..967f0570a 100644\n--- a/lib/crates/fabro-workflow/src/event/sink.rs\n+++ b/lib/crates/fabro-workflow/src/event/sink.rs\n@@ -247,6 +247,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\ndiff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs\nindex 98c916d9b..5a7db2ed7 100644\n--- a/lib/crates/fabro-workflow/src/git.rs\n+++ b/lib/crates/fabro-workflow/src/git.rs\n@@ -472,6 +472,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\ndiff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs\nindex 24feb5650..02e1c7612 100644\n--- a/lib/crates/fabro-workflow/src/handler/agent.rs\n+++ b/lib/crates/fabro-workflow/src/handler/agent.rs\n@@ -479,6 +479,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n },\ndiff --git a/lib/crates/fabro-workflow/src/handler/command.rs b/lib/crates/fabro-workflow/src/handler/command.rs\nindex 8e110e202..63922632a 100644\n--- a/lib/crates/fabro-workflow/src/handler/command.rs\n+++ b/lib/crates/fabro-workflow/src/handler/command.rs\n@@ -357,6 +357,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n },\ndiff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs\nindex 38f20a5d9..deb8e0e4a 100644\n--- a/lib/crates/fabro-workflow/src/handler/llm/api.rs\n+++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs\n@@ -1714,6 +1714,7 @@ mod tests {\n pull_request: None,\n current_question: None,\n superseded_by: None,\n+ retried_from: None,\n links: RunLinks { web: None },\n }\n }\ndiff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs\nindex 4ec7fbcd8..eeee23acc 100644\n--- a/lib/crates/fabro-workflow/src/handler/parallel.rs\n+++ b/lib/crates/fabro-workflow/src/handler/parallel.rs\n@@ -729,6 +729,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n },\ndiff --git a/lib/crates/fabro-workflow/src/handler/prompt.rs b/lib/crates/fabro-workflow/src/handler/prompt.rs\nindex 625e4597f..64cf80c90 100644\n--- a/lib/crates/fabro-workflow/src/handler/prompt.rs\n+++ b/lib/crates/fabro-workflow/src/handler/prompt.rs\n@@ -273,6 +273,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n },\ndiff --git a/lib/crates/fabro-workflow/src/lifecycle/git.rs b/lib/crates/fabro-workflow/src/lifecycle/git.rs\nindex 6dcd85fe6..bc34009d9 100644\n--- a/lib/crates/fabro-workflow/src/lifecycle/git.rs\n+++ b/lib/crates/fabro-workflow/src/lifecycle/git.rs\n@@ -730,6 +730,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\ndiff --git a/lib/crates/fabro-workflow/src/operations/archive.rs b/lib/crates/fabro-workflow/src/operations/archive.rs\nindex 34ca62650..f2ebdce80 100644\n--- a/lib/crates/fabro-workflow/src/operations/archive.rs\n+++ b/lib/crates/fabro-workflow/src/operations/archive.rs\n@@ -226,6 +226,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\ndiff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs\nindex 851420cf2..59944c477 100644\n--- a/lib/crates/fabro-workflow/src/operations/create.rs\n+++ b/lib/crates/fabro-workflow/src/operations/create.rs\n@@ -250,6 +250,7 @@ async fn persist_created_run(\n manifest_blob,\n git: record.git.clone(),\n fork_source_ref: record.fork_source_ref.clone(),\n+ retried_from: None,\n parent_id,\n web_url,\n },\ndiff --git a/lib/crates/fabro-workflow/src/operations/fork.rs b/lib/crates/fabro-workflow/src/operations/fork.rs\nindex 3731e6145..52cfbc140 100644\n--- a/lib/crates/fabro-workflow/src/operations/fork.rs\n+++ b/lib/crates/fabro-workflow/src/operations/fork.rs\n@@ -166,6 +166,7 @@ async fn persist_forked_run(\n manifest_blob: spec.manifest_blob,\n git: spec.git.clone(),\n fork_source_ref: spec.fork_source_ref.clone(),\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\n@@ -380,6 +381,7 @@ mod tests {\n push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,\n }),\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\ndiff --git a/lib/crates/fabro-workflow/src/operations/mod.rs b/lib/crates/fabro-workflow/src/operations/mod.rs\nindex 462377b91..7bf6c5311 100644\n--- a/lib/crates/fabro-workflow/src/operations/mod.rs\n+++ b/lib/crates/fabro-workflow/src/operations/mod.rs\n@@ -2,6 +2,7 @@ mod archive;\n mod create;\n mod fork;\n mod resume;\n+mod retry;\n mod rewind;\n mod run_store;\n mod source;\n@@ -16,6 +17,7 @@ pub use archive::{\n pub use create::{CreateRunInput, CreatedRun, create, make_run_dir};\n pub use fork::{ForkOutcome, ForkRunInput, ResolvedForkTarget, fork_run};\n pub use resume::resume;\n+pub use retry::{RetryOutcome, RetryRunInput, retry_run};\n pub use rewind::{RewindInput, RewindOutcome, rewind};\n pub use source::WorkflowInput;\n pub use start::{StartServices, Started, start};\ndiff --git a/lib/crates/fabro-workflow/src/operations/retry.rs b/lib/crates/fabro-workflow/src/operations/retry.rs\nnew file mode 100644\nindex 000000000..b381e17a0\n--- /dev/null\n+++ b/lib/crates/fabro-workflow/src/operations/retry.rs\n@@ -0,0 +1,490 @@\n+use std::collections::BTreeMap;\n+\n+use fabro_store::Database;\n+use fabro_types::{FailureReason, RunId, RunProvenance, RunStatus};\n+\n+use super::archive::ensure_not_archived;\n+use super::run_store::map_open_run_error;\n+use crate::error::Error;\n+use crate::event::{self, Event};\n+\n+#[derive(Debug, Clone)]\n+pub struct RetryRunInput {\n+ pub source_run_id: RunId,\n+ pub new_run_id: Option,\n+ pub provenance: Option,\n+ pub web_url: Option,\n+}\n+\n+#[derive(Debug, Clone, PartialEq, Eq)]\n+pub struct RetryOutcome {\n+ pub source_run_id: RunId,\n+ pub new_run_id: RunId,\n+}\n+\n+pub async fn retry_run(\n+ store: &Database,\n+ input: &RetryRunInput,\n+) -> std::result::Result {\n+ let source_run_id = input.source_run_id;\n+ let source_store = store\n+ .open_run(&source_run_id)\n+ .await\n+ .map_err(|err| map_open_run_error(&source_run_id, err))?;\n+ let source = source_store\n+ .state()\n+ .await\n+ .map_err(|err| Error::engine(err.to_string()))?;\n+\n+ ensure_not_archived(source.archived_at.is_some(), &source_run_id)?;\n+ ensure_retryable(source.status, &source_run_id)?;\n+\n+ let mut spec = source.spec.clone();\n+ let new_run_id = input.new_run_id.unwrap_or_default();\n+ spec.run_id = new_run_id;\n+ spec.provenance = input.provenance.clone();\n+\n+ let retry_store = store\n+ .create_run(&new_run_id)\n+ .await\n+ .map_err(|err| Error::engine(err.to_string()))?;\n+\n+ event::append_event(&retry_store, &new_run_id, &Event::RunCreated {\n+ run_id: new_run_id,\n+ title: Some(source.title().into_owned()),\n+ settings: serde_json::to_value(&spec.settings)\n+ .map_err(|err| Error::engine(err.to_string()))?,\n+ graph: serde_json::to_value(&spec.graph)\n+ .map_err(|err| Error::engine(err.to_string()))?,\n+ workflow_source: spec.graph_source.clone(),\n+ workflow_config: None,\n+ labels: spec.labels.clone().into_iter().collect::>(),\n+ run_dir: String::new(),\n+ source_directory: spec.source_directory.clone(),\n+ workflow_slug: spec.workflow_slug.clone(),\n+ db_prefix: None,\n+ provenance: spec.provenance.clone(),\n+ manifest_blob: spec.manifest_blob,\n+ git: spec.git.clone(),\n+ fork_source_ref: spec.fork_source_ref.clone(),\n+ retried_from: Some(source_run_id),\n+ parent_id: source.parent_id,\n+ web_url: input.web_url.clone(),\n+ })\n+ .await\n+ .map_err(|err| Error::engine(err.to_string()))?;\n+\n+ event::append_event(&retry_store, &new_run_id, &Event::RunSubmitted {\n+ definition_blob: spec.definition_blob,\n+ })\n+ .await\n+ .map_err(|err| Error::engine(err.to_string()))?;\n+\n+ Ok(RetryOutcome {\n+ source_run_id,\n+ new_run_id,\n+ })\n+}\n+\n+fn ensure_retryable(status: RunStatus, run_id: &RunId) -> std::result::Result<(), Error> {\n+ match status {\n+ RunStatus::Failed {\n+ reason: FailureReason::Cancelled,\n+ } => Err(Error::Precondition(format!(\n+ \"run {run_id} was cancelled and cannot be retried\"\n+ ))),\n+ RunStatus::Failed { .. } | RunStatus::Dead => Ok(()),\n+ other => Err(Error::Precondition(format!(\n+ \"run {run_id} cannot be retried from status {other}; expected failed or dead\"\n+ ))),\n+ }\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use std::collections::{BTreeMap, HashMap};\n+ use std::sync::Arc;\n+ use std::time::Duration;\n+\n+ use fabro_store::{Database, RunProjectionReducer};\n+ use fabro_types::{\n+ AuthMethod, DirtyStatus, ForkSourceRef, GitContext, Graph, IdpIdentity, PreRunPushOutcome,\n+ Principal, PullRequestLink, RunBlobId, RunServerProvenance, RunTiming, UserPrincipal,\n+ WorkflowSettings, fixtures,\n+ };\n+ use object_store::memory::InMemory;\n+\n+ use super::*;\n+\n+ fn memory_store() -> Database {\n+ Database::new(\n+ Arc::new(InMemory::new()),\n+ \"\",\n+ Duration::from_millis(1),\n+ None,\n+ )\n+ }\n+\n+ fn actor(login: &str) -> Principal {\n+ Principal::User(UserPrincipal {\n+ identity: IdpIdentity::new(\"github\", format!(\"user:{login}\")).unwrap(),\n+ login: login.to_string(),\n+ auth_method: AuthMethod::DevToken,\n+ avatar_url: None,\n+ })\n+ }\n+\n+ fn provenance(login: &str) -> RunProvenance {\n+ RunProvenance {\n+ server: Some(RunServerProvenance {\n+ version: \"test\".to_string(),\n+ }),\n+ client: None,\n+ subject: Some(actor(login)),\n+ }\n+ }\n+\n+ fn git_context() -> GitContext {\n+ GitContext {\n+ origin_url: \"https://github.com/fabro-sh/fabro.git\".to_string(),\n+ branch: \"main\".to_string(),\n+ sha: Some(\"abc123\".to_string()),\n+ dirty: DirtyStatus::Clean,\n+ push_outcome: PreRunPushOutcome::NotAttempted,\n+ }\n+ }\n+\n+ async fn append_created(\n+ store: &fabro_store::RunDatabase,\n+ run_id: RunId,\n+ manifest_blob: Option,\n+ fork_source_ref: Option,\n+ ) {\n+ let mut settings = WorkflowSettings::default();\n+ settings\n+ .run\n+ .metadata\n+ .insert(\"env\".to_string(), \"test\".to_string());\n+ let labels = HashMap::from([(\"team\".to_string(), \"core\".to_string())]);\n+ event::append_event(store, &run_id, &Event::RunCreated {\n+ run_id,\n+ title: Some(\"Original title\".to_string()),\n+ settings: serde_json::to_value(&settings).unwrap(),\n+ graph: serde_json::to_value(Graph::new(\"retry_source\")).unwrap(),\n+ workflow_source: Some(\"digraph retry_source { start -> exit }\".to_string()),\n+ workflow_config: None,\n+ labels: labels.into_iter().collect(),\n+ run_dir: \"/tmp/source\".to_string(),\n+ source_directory: Some(\"/workspace/source\".to_string()),\n+ workflow_slug: Some(\"retry-source\".to_string()),\n+ db_prefix: None,\n+ provenance: Some(provenance(\"source-user\")),\n+ manifest_blob,\n+ git: Some(git_context()),\n+ fork_source_ref,\n+ retried_from: None,\n+ parent_id: None,\n+ web_url: None,\n+ })\n+ .await\n+ .unwrap();\n+ }\n+\n+ async fn append_failed(store: &fabro_store::RunDatabase, run_id: RunId, reason: FailureReason) {\n+ event::append_event(store, &run_id, &Event::RunStarting)\n+ .await\n+ .unwrap();\n+ event::append_event(store, &run_id, &Event::RunRunning)\n+ .await\n+ .unwrap();\n+ let event = Event::workflow_run_failed_from_error(\n+ &Error::engine(\"boom\"),\n+ RunTiming::wall_only(10),\n+ reason,\n+ None,\n+ None,\n+ None,\n+ None,\n+ );\n+ event::append_event(store, &run_id, &event).await.unwrap();\n+ }\n+\n+ async fn seed_retryable_failed_source(\n+ store: &Database,\n+ source_run_id: RunId,\n+ ) -> (Option, Option, ForkSourceRef) {\n+ let source_store = store.create_run(&source_run_id).await.unwrap();\n+ let manifest_blob = Some(\n+ source_store\n+ .write_blob(br#\"{\\\"manifest\\\":true}\"#)\n+ .await\n+ .unwrap(),\n+ );\n+ let definition_blob = Some(\n+ source_store\n+ .write_blob(br#\"{\\\"definition\\\":true}\"#)\n+ .await\n+ .unwrap(),\n+ );\n+ let fork_source_ref = ForkSourceRef {\n+ source_run_id: fixtures::RUN_3,\n+ checkpoint_sha: \"fork-sha\".to_string(),\n+ };\n+ append_created(\n+ &source_store,\n+ source_run_id,\n+ manifest_blob,\n+ Some(fork_source_ref.clone()),\n+ )\n+ .await;\n+ event::append_event(&source_store, &source_run_id, &Event::RunSubmitted {\n+ definition_blob,\n+ })\n+ .await\n+ .unwrap();\n+ event::append_event(&source_store, &source_run_id, &Event::RunParentLinked {\n+ previous_parent_id: None,\n+ parent_id: fixtures::RUN_2,\n+ actor: None,\n+ })\n+ .await\n+ .unwrap();\n+ event::append_event(&source_store, &source_run_id, &Event::RunTitleUpdated {\n+ title: \"Current title\".to_string(),\n+ actor: None,\n+ })\n+ .await\n+ .unwrap();\n+ event::append_event(&source_store, &source_run_id, &Event::CheckpointCompleted {\n+ node_id: \"work\".to_string(),\n+ status: \"succeeded\".to_string(),\n+ current_node: \"work\".to_string(),\n+ completed_nodes: vec![\"work\".to_string()],\n+ node_retries: BTreeMap::new(),\n+ context_values: BTreeMap::new(),\n+ node_outcomes: BTreeMap::new(),\n+ next_node_id: None,\n+ git_commit_sha: Some(\"checkpoint-sha\".to_string()),\n+ loop_failure_signatures: BTreeMap::new(),\n+ restart_failure_signatures: BTreeMap::new(),\n+ node_visits: BTreeMap::new(),\n+ diff: Some(\"diff --git a/file b/file\".to_string()),\n+ diff_summary: Some(fabro_types::DiffSummary {\n+ files_changed: 1,\n+ additions: 1,\n+ deletions: 0,\n+ }),\n+ })\n+ .await\n+ .unwrap();\n+ event::append_event(&source_store, &source_run_id, &Event::SandboxInitialized {\n+ provider: fabro_types::SandboxProvider::Local,\n+ id: \"sandbox-source\".to_string(),\n+ working_directory: \"/tmp/source\".to_string(),\n+ repo_cloned: None,\n+ clone_origin_url: None,\n+ clone_branch: None,\n+ workspace_root: None,\n+ repos_root: None,\n+ primary_repo_path: None,\n+ primary_repo_link: None,\n+ })\n+ .await\n+ .unwrap();\n+ event::append_event(&source_store, &source_run_id, &Event::PullRequestLinked {\n+ pull_request: PullRequestLink {\n+ owner: \"fabro-sh\".to_string(),\n+ repo: \"fabro\".to_string(),\n+ number: 42,\n+ },\n+ })\n+ .await\n+ .unwrap();\n+ append_failed(&source_store, source_run_id, FailureReason::WorkflowError).await;\n+ (manifest_blob, definition_blob, fork_source_ref)\n+ }\n+\n+ #[tokio::test]\n+ async fn retry_creates_fresh_run_from_durable_definition_only() {\n+ let store = memory_store();\n+ let source_run_id = fixtures::RUN_1;\n+ let (manifest_blob, definition_blob, fork_source_ref) =\n+ seed_retryable_failed_source(&store, source_run_id).await;\n+ let source_event_count = store\n+ .open_run(&source_run_id)\n+ .await\n+ .unwrap()\n+ .list_events()\n+ .await\n+ .unwrap()\n+ .len();\n+\n+ let outcome = retry_run(&store, &RetryRunInput {\n+ source_run_id,\n+ new_run_id: None,\n+ provenance: Some(provenance(\"retry-user\")),\n+ web_url: Some(\"http://localhost:3000/runs/retry\".to_string()),\n+ })\n+ .await\n+ .unwrap();\n+\n+ assert_ne!(outcome.new_run_id, source_run_id);\n+ assert_eq!(outcome.source_run_id, source_run_id);\n+\n+ let retry_store = store.open_run(&outcome.new_run_id).await.unwrap();\n+ let retry_events = retry_store.list_events().await.unwrap();\n+ let retry_state = fabro_store::RunProjection::apply_events(&retry_events).unwrap();\n+ assert_eq!(retry_events.len(), 2);\n+ assert_eq!(retry_state.status, RunStatus::Submitted);\n+ assert_eq!(retry_state.retried_from, Some(source_run_id));\n+ assert_eq!(retry_state.parent_id, Some(fixtures::RUN_2));\n+ assert_eq!(retry_state.title(), \"Current title\");\n+ assert_eq!(\n+ retry_state.spec.labels.get(\"team\"),\n+ Some(&\"core\".to_string())\n+ );\n+ assert_eq!(\n+ retry_state.spec.settings.run.metadata.get(\"env\"),\n+ Some(&\"test\".to_string())\n+ );\n+ assert_eq!(retry_state.spec.graph.name, \"retry_source\");\n+ assert_eq!(\n+ retry_state.spec.graph_source.as_deref(),\n+ Some(\"digraph retry_source { start -> exit }\")\n+ );\n+ assert_eq!(retry_state.spec.git, Some(git_context()));\n+ assert_eq!(retry_state.spec.manifest_blob, manifest_blob);\n+ assert_eq!(retry_state.spec.definition_blob, definition_blob);\n+ assert_eq!(retry_state.spec.fork_source_ref, Some(fork_source_ref));\n+ assert_eq!(\n+ retry_state\n+ .spec\n+ .provenance\n+ .as_ref()\n+ .and_then(|provenance| provenance.subject.as_ref()),\n+ Some(&actor(\"retry-user\"))\n+ );\n+ assert_eq!(\n+ retry_state.web_url.as_deref(),\n+ Some(\"http://localhost:3000/runs/retry\")\n+ );\n+\n+ assert!(retry_state.checkpoints.is_empty());\n+ assert!(retry_state.conclusion.is_none());\n+ assert!(retry_state.pull_request.is_none());\n+ assert!(retry_state.pending_interviews.is_empty());\n+ assert!(retry_state.pending_control.is_none());\n+ assert!(\n+ retry_state\n+ .sandbox\n+ .as_ref()\n+ .and_then(|sandbox| sandbox.runtime.as_ref())\n+ .is_none()\n+ );\n+\n+ let source_store = store.open_run(&source_run_id).await.unwrap();\n+ assert_eq!(\n+ source_store.list_events().await.unwrap().len(),\n+ source_event_count\n+ );\n+ assert_eq!(\n+ source_store.state().await.unwrap().status,\n+ RunStatus::Failed {\n+ reason: FailureReason::WorkflowError,\n+ }\n+ );\n+ }\n+\n+ #[tokio::test]\n+ async fn retry_rejects_non_retryable_sources() {\n+ let store = memory_store();\n+\n+ let succeeded = fixtures::RUN_1;\n+ let succeeded_store = store.create_run(&succeeded).await.unwrap();\n+ append_created(&succeeded_store, succeeded, None, None).await;\n+ event::append_event(&succeeded_store, &succeeded, &Event::RunStarting)\n+ .await\n+ .unwrap();\n+ event::append_event(&succeeded_store, &succeeded, &Event::RunRunning)\n+ .await\n+ .unwrap();\n+ event::append_event(&succeeded_store, &succeeded, &Event::WorkflowRunCompleted {\n+ timing: RunTiming::wall_only(10),\n+ artifact_count: 0,\n+ status: \"succeeded\".to_string(),\n+ reason: fabro_types::SuccessReason::Completed,\n+ total_usd_micros: None,\n+ final_git_commit_sha: None,\n+ final_patch: None,\n+ diff_summary: None,\n+ billing: None,\n+ })\n+ .await\n+ .unwrap();\n+\n+ let active = fixtures::RUN_2;\n+ let active_store = store.create_run(&active).await.unwrap();\n+ append_created(&active_store, active, None, None).await;\n+ event::append_event(&active_store, &active, &Event::RunSubmitted {\n+ definition_blob: None,\n+ })\n+ .await\n+ .unwrap();\n+ event::append_event(&active_store, &active, &Event::RunQueued)\n+ .await\n+ .unwrap();\n+\n+ let cancelled = fixtures::RUN_3;\n+ let cancelled_store = store.create_run(&cancelled).await.unwrap();\n+ append_created(&cancelled_store, cancelled, None, None).await;\n+ append_failed(&cancelled_store, cancelled, FailureReason::Cancelled).await;\n+\n+ let archived = fixtures::RUN_4;\n+ let archived_store = store.create_run(&archived).await.unwrap();\n+ append_created(&archived_store, archived, None, None).await;\n+ append_failed(&archived_store, archived, FailureReason::WorkflowError).await;\n+ event::append_event(&archived_store, &archived, &Event::RunArchived {\n+ actor: None,\n+ })\n+ .await\n+ .unwrap();\n+\n+ for run_id in [succeeded, active, cancelled, archived] {\n+ let err = retry_run(&store, &RetryRunInput {\n+ source_run_id: run_id,\n+ new_run_id: None,\n+ provenance: None,\n+ web_url: None,\n+ })\n+ .await\n+ .unwrap_err();\n+ assert!(\n+ matches!(err, Error::Precondition(_)),\n+ \"unexpected error: {err:?}\"\n+ );\n+ }\n+ }\n+\n+ #[tokio::test]\n+ async fn retry_reports_missing_source() {\n+ let store = memory_store();\n+ let err = retry_run(&store, &RetryRunInput {\n+ source_run_id: fixtures::RUN_1,\n+ new_run_id: None,\n+ provenance: None,\n+ web_url: None,\n+ })\n+ .await\n+ .unwrap_err();\n+\n+ assert!(\n+ matches!(err, Error::RunNotFound(_)),\n+ \"unexpected error: {err:?}\"\n+ );\n+ }\n+\n+ #[test]\n+ fn dead_status_is_retryable() {\n+ ensure_retryable(RunStatus::Dead, &fixtures::RUN_1).unwrap();\n+ }\n+}\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs\nindex f74c131cb..2e685e359 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs\n@@ -211,6 +211,7 @@ async fn seed_created_and_starting(\n manifest_blob: None,\n git: run_options.pre_run_git.clone(),\n fork_source_ref: run_options.fork_source_ref.clone(),\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs\nindex 35fdf1d3f..53692daab 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs\n@@ -742,6 +742,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/persist.rs b/lib/crates/fabro-workflow/src/pipeline/persist.rs\nindex b760bc571..ee6150696 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/persist.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/persist.rs\n@@ -173,6 +173,7 @@ mod tests {\n manifest_blob: None,\n git: record.git.clone(),\n fork_source_ref: record.fork_source_ref.clone(),\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs\nindex a04f6ab8c..e02497690 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs\n@@ -1167,6 +1167,7 @@ mod tests {\n manifest_blob: None,\n git: run_spec.git.clone(),\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\n@@ -1235,6 +1236,7 @@ mod tests {\n manifest_blob: None,\n git: run_spec.git.clone(),\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\n@@ -1588,6 +1590,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\n@@ -1708,6 +1711,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\n@@ -1876,6 +1880,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\ndiff --git a/lib/crates/fabro-workflow/src/run_lookup.rs b/lib/crates/fabro-workflow/src/run_lookup.rs\nindex 176bbc144..ff95659b7 100644\n--- a/lib/crates/fabro-workflow/src/run_lookup.rs\n+++ b/lib/crates/fabro-workflow/src/run_lookup.rs\n@@ -522,6 +522,7 @@ mod tests {\n manifest_blob: None,\n git: run_spec.git.clone(),\n fork_source_ref: run_spec.fork_source_ref.clone(),\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\ndiff --git a/lib/crates/fabro-workflow/src/runtime_store.rs b/lib/crates/fabro-workflow/src/runtime_store.rs\nindex b32ef1c03..0f590c70f 100644\n--- a/lib/crates/fabro-workflow/src/runtime_store.rs\n+++ b/lib/crates/fabro-workflow/src/runtime_store.rs\n@@ -172,6 +172,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\ndiff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs\nindex aa9c8117a..c921041d2 100644\n--- a/lib/crates/fabro-workflow/src/test_support.rs\n+++ b/lib/crates/fabro-workflow/src/test_support.rs\n@@ -127,6 +127,7 @@ async fn initialized(\n manifest_blob: None,\n git: run_options.pre_run_git.clone(),\n fork_source_ref: run_options.fork_source_ref.clone(),\n+ retried_from: None,\n parent_id: None,\n web_url: None,\n })\ndiff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES\nindex 653eea117..e5f0492ec 100644\n--- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES\n+++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES\n@@ -410,6 +410,10 @@ models/system-resources-response.ts\n models/system-run-counts.ts\n models/timeline-entry-response.ts\n models/tls-mode.ts\n+models/todo-list-kind.ts\n+models/todo-list-projection.ts\n+models/todo-projection.ts\n+models/todo-status.ts\n models/update-run-parent-request.ts\n models/update-run-request.ts\n models/user-response.ts\n@@ -425,4 +429,4 @@ models/workflow-ref.ts\n models/workflow-reference.ts\n models/workflow-schedule-summary.ts\n models/workflow-settings.ts\n-models/write-blob-response.ts\n\\ No newline at end of file\n+models/write-blob-response.ts\ndiff --git a/lib/packages/fabro-api-client/src/api/runs-api.ts b/lib/packages/fabro-api-client/src/api/runs-api.ts\nindex a5199a2f0..9ee245fc4 100644\n--- a/lib/packages/fabro-api-client/src/api/runs-api.ts\n+++ b/lib/packages/fabro-api-client/src/api/runs-api.ts\n@@ -939,6 +939,46 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)\n options: localVarRequestOptions,\n };\n },\n+ /**\n+ * Creates a fresh run from the failed or dead source run\\'s captured durable definition, records `retried_from` on the new run, and queues it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.\n+ * @summary Retry Run\n+ * @param {string} id Unique run identifier (ULID).\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ retryRun: async (id: string, options: RawAxiosRequestConfig = {}): Promise => {\n+ // verify required parameter 'id' is not null or undefined\n+ assertParamExists('retryRun', 'id', id)\n+ const localVarPath = `/api/v1/runs/{id}/retry`\n+ .replace(`{${\"id\"}}`, encodeURIComponent(String(id)));\n+ // use dummy base URL string because the URL constructor only accepts absolute URLs.\n+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);\n+ let baseOptions;\n+ if (configuration) {\n+ baseOptions = configuration.baseOptions;\n+ }\n+\n+ const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};\n+ const localVarHeaderParameter = {} as any;\n+ const localVarQueryParameter = {} as any;\n+\n+ // authentication SessionCookie required\n+\n+ // authentication BearerAuth required\n+ // http bearer authentication required\n+ await setBearerAuthToObject(localVarHeaderParameter, configuration)\n+\n+ localVarHeaderParameter['Accept'] = 'application/json';\n+\n+ setSearchParams(localVarUrlObj, localVarQueryParameter);\n+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};\n+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};\n+\n+ return {\n+ url: toPathString(localVarUrlObj),\n+ options: localVarRequestOptions,\n+ };\n+ },\n /**\n * Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.\n * @summary Rewind Run\n@@ -1593,6 +1633,19 @@ export const RunsApiFp = function(configuration?: Configuration) {\n const localVarOperationServerBasePath = operationServerMap['RunsApi.retrieveRunGraphSource']?.[localVarOperationServerIndex]?.url;\n return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);\n },\n+ /**\n+ * Creates a fresh run from the failed or dead source run\\'s captured durable definition, records `retried_from` on the new run, and queues it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.\n+ * @summary Retry Run\n+ * @param {string} id Unique run identifier (ULID).\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ async retryRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {\n+ const localVarAxiosArgs = await localVarAxiosParamCreator.retryRun(id, options);\n+ const localVarOperationServerIndex = configuration?.serverIndex ?? 0;\n+ const localVarOperationServerBasePath = operationServerMap['RunsApi.retryRun']?.[localVarOperationServerIndex]?.url;\n+ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);\n+ },\n /**\n * Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.\n * @summary Rewind Run\n@@ -1934,6 +1987,16 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?\n retrieveRunGraphSource(id: string, options?: RawAxiosRequestConfig): AxiosPromise {\n return localVarFp.retrieveRunGraphSource(id, options).then((request) => request(axios, basePath));\n },\n+ /**\n+ * Creates a fresh run from the failed or dead source run\\'s captured durable definition, records `retried_from` on the new run, and queues it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.\n+ * @summary Retry Run\n+ * @param {string} id Unique run identifier (ULID).\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ retryRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise {\n+ return localVarFp.retryRun(id, options).then((request) => request(axios, basePath));\n+ },\n /**\n * Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.\n * @summary Rewind Run\n@@ -2266,6 +2329,17 @@ export class RunsApi extends BaseAPI {\n return RunsApiFp(this.configuration).retrieveRunGraphSource(id, options).then((request) => request(this.axios, this.basePath));\n }\n \n+ /**\n+ * Creates a fresh run from the failed or dead source run\\'s captured durable definition, records `retried_from` on the new run, and queues it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.\n+ * @summary Retry Run\n+ * @param {string} id Unique run identifier (ULID).\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ public retryRun(id: string, options?: RawAxiosRequestConfig) {\n+ return RunsApiFp(this.configuration).retryRun(id, options).then((request) => request(this.axios, this.basePath));\n+ }\n+\n /**\n * Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.\n * @summary Rewind Run\ndiff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts\nindex 23a07a598..af08896d8 100644\n--- a/lib/packages/fabro-api-client/src/models/index.ts\n+++ b/lib/packages/fabro-api-client/src/models/index.ts\n@@ -405,4 +405,4 @@ export * from './workflow-ref';\n export * from './workflow-reference';\n export * from './workflow-schedule-summary';\n export * from './workflow-settings';\n-export * from './write-blob-response';\n\\ No newline at end of file\n+export * from './write-blob-response';\ndiff --git a/lib/packages/fabro-api-client/src/models/run-checkpoint-settings.ts b/lib/packages/fabro-api-client/src/models/run-checkpoint-settings.ts\nindex 353e5f24d..4af156da7 100644\n--- a/lib/packages/fabro-api-client/src/models/run-checkpoint-settings.ts\n+++ b/lib/packages/fabro-api-client/src/models/run-checkpoint-settings.ts\n@@ -18,8 +18,6 @@ export interface RunCheckpointSettings {\n 'exclude_globs': Array;\n /**\n * When true, Fabro-managed run-branch checkpoint commits bypass local Git commit hooks. Does not affect Fabro `[[run.hooks]]` or metadata-branch snapshots. Defaults to false.\n- * @type {boolean}\n- * @memberof RunCheckpointSettings\n */\n 'skip_git_hooks': boolean;\n }\ndiff --git a/lib/packages/fabro-api-client/src/models/run-projection.ts b/lib/packages/fabro-api-client/src/models/run-projection.ts\nindex 0505c0cd0..dd2d01a1d 100644\n--- a/lib/packages/fabro-api-client/src/models/run-projection.ts\n+++ b/lib/packages/fabro-api-client/src/models/run-projection.ts\n@@ -78,6 +78,10 @@ export interface RunProjection {\n 'sandbox'?: RunSandbox | null;\n 'pull_request'?: PullRequestLink | null;\n 'superseded_by'?: string | null;\n+ /**\n+ * Source run ID when this run was created by manual retry.\n+ */\n+ 'retried_from'?: string | null;\n 'pending_interviews': { [key: string]: PendingInterviewRecord; };\n /**\n * Projected todo / task lists, keyed by `list_id` (`openai_plan:` or `anthropic_tasks:`). Built by replaying `todo.created`, `todo.updated`, and `todo.deleted` events.\n@@ -87,4 +91,4 @@ export interface RunProjection {\n * Map from StageId (`node_id@visit`) to stage projection data.\n */\n 'stages': { [key: string]: StageProjection; };\n-}\n\\ No newline at end of file\n+}\ndiff --git a/lib/packages/fabro-api-client/src/models/run.ts b/lib/packages/fabro-api-client/src/models/run.ts\nindex cf6c4c51e..dff57ae5a 100644\n--- a/lib/packages/fabro-api-client/src/models/run.ts\n+++ b/lib/packages/fabro-api-client/src/models/run.ts\n@@ -94,6 +94,13 @@ export interface Run {\n 'diff': DiffSummary | null;\n 'pull_request': PullRequestLink | null;\n 'current_question': RunQuestion | null;\n+ /**\n+ * Run ID that superseded this run via rewind, if any.\n+ */\n 'superseded_by': string | null;\n+ /**\n+ * Source run ID when this run was created by manual retry.\n+ */\n+ 'retried_from': string | null;\n 'links': RunLinks;\n }\n", + "summary": { + "files_changed": 56, + "additions": 1177, + "deletions": 45 + } + } + }, + { + "seq": 0, + "checkpoint": { + "timestamp": "2026-05-23T10:59:48.752288Z", + "current_node": "simplify_opus", + "completed_nodes": [ + "start", + "toolchain", + "preflight_compile", + "preflight_lint", + "implement", + "simplify_opus" + ], + "node_retries": {}, + "context_values": { "internal.work_dir": "/home/daytona/workspace/fabro", "internal.retry_count.start": 0, - "failure_signature": "", - "last_stage": "implement", "internal.retry_count.implement": 0, "internal.run_id": "01KSA4H7JHBRPXZM3XTJ4SD9QA", - "internal.retry_count.preflight_lint": 0, + "internal.retry_count.simplify_opus": 0, "internal.node_visit_count": 1, "graph.goal": "---\ntitle: Add Manual Run Retry\ntype: feat\nstatus: active\ndate: 2026-05-23\n---\n\n# Add Manual Run Retry\n\n## Summary\n\nAdd a **Retry** action for failed Fabro runs that creates and immediately starts a new run from the failed run's captured run definition. The new run is independent runtime state, records `retried_from: `, and leaves the source run unchanged.\n\nThis is a fresh run, not resume/fork/rewind. It should copy the source run's durable definition and settings, but not checkpoints, stage state, sandbox state, PR links, billing, questions, conclusions, or pending controls.\n\n## Key Changes\n\n- Add `retried_from` as a nullable public field on `Run`.\n - Store it on the new run only.\n - Do not add a reverse `retried_by` field in v1.\n - Preserve backward compatibility with old events by defaulting to `null`.\n\n- Add `POST /api/v1/runs/{id}/retry`.\n - Response: `201` with the newly created/queued `Run`.\n - Eligible source states: `failed` except `reason=cancelled`, and `dead`.\n - Reject active, succeeded, cancelled, archived, and missing runs with existing API error patterns.\n - The new run should use the current authenticated actor as `created_by`.\n - The new run should preserve the source run's current `parent_id`, title, labels, workflow graph/source, resolved settings, git context, manifest/definition blob refs, and `fork_source_ref` if present.\n\n- Implement retry using a workflow operation similar in shape to `fork`, but without replaying checkpoint/runtime events.\n - Create a new run store.\n - Append `run.created` with `retried_from`.\n - Append `run.submitted`.\n - Queue/start it through the same internal start path used by `POST /runs/{id}/start`.\n\n- Update OpenAPI and generated clients.\n - Edit `docs/public/api-reference/fabro-api.yaml`.\n - Regenerate Rust API types through `cargo build -p fabro-api`.\n - Regenerate TypeScript client in `lib/packages/fabro-api-client`.\n\n- Update the web UI.\n - Add `Retry` to the run action menu for eligible failed/dead runs.\n - Disable the action while pending.\n - On success, navigate to the new run page and refresh run/list caches.\n - Add a compact \"Retried from\" link in the run summary panel when `retried_from` is present.\n - Add demo-mode support or hide the action in demo mode so the button never navigates to a missing demo run.\n\n## Test Plan\n\n- Rust workflow/store tests:\n - `run.created` serializes/deserializes `retried_from`.\n - Old `run.created` events project with `retried_from = None`.\n - Retry creates a new run with a different ID, copied durable definition, no runtime state, and `retried_from` set.\n - Retry preserves current `parent_id`, title, labels, git context, settings, and `fork_source_ref`.\n - Retry rejects succeeded, active, cancelled, and archived source runs.\n\n- Rust server/API tests:\n - `POST /runs/{id}/retry` on a failed run returns `201`, a new run ID, `retried_from`, and queued/started lifecycle state.\n - Source run remains unchanged.\n - `404` for unknown run.\n - `409` for non-retryable status.\n - Generated Rust API compiles against the updated OpenAPI contract.\n\n- Web tests:\n - `canRetry` returns true for failed/dead, false for cancelled/succeeded/active/archived.\n - Action menu renders `Retry` only when eligible.\n - Successful retry calls the generated client and navigates to `/runs/:newId`.\n - Retry errors show a useful toast/message.\n - Run summary panel renders the `Retried from` link when present.\n - Typecheck passes with regenerated client types.\n\n## Assumptions\n\n- V1 does not add a CLI `fabro retry` command.\n- V1 does not add automatic retry attempts, retry counts, or idempotency keys.\n- Multiple manual clicks after the first request completes may create multiple retry runs.\n- \"Same settings\" means the source run's captured durable definition/settings, not latest local files from the user's machine.\n- Cancelled runs are excluded because cancellation is user intent, not execution failure.\n", "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "internal.retry_count.preflight_compile": 0, + "internal.fidelity": "compact", + "thread.start.current_node": "toolchain", + "internal.retry_count.toolchain": 0, + "outcome": "succeeded", + "response.simplify_opus": "Net 32-line reduction. Here's the summary of fixes applied:\n\n## Summary\n\nThree review agents flagged duplication and clone churn in the retry feature. I applied these focused fixes:\n\n**Rust (`retry.rs`, `lifecycle.rs`):**\n- Removed dead writes (`spec.run_id = ...`, `spec.provenance = ...`) and double clones of every spec field. Destructured `source.spec` by move so each field flows directly into the `RunCreated` event without an intermediate `RunSpec` clone.\n- Changed `RetryRunInput::new_run_id` from `Option` to required `RunId` — the handler always pre-generates one to compute `web_url`; the operation no longer carries a phantom default.\n\n**Frontend (`run-actions.ts`, `mutations.ts`, `run-detail.tsx`):**\n- Added `\"retry\"` to the `LifecycleAction` union and folded `retryErrorMessage` into `mapError` (both had identical 404 + detail + fallback shape).\n- Dropped `RetryMutationResult` — now uses the existing `LifecycleMutationResult` with `intent: \"retry\"`.\n- Folded `useRetryRun` into `useLifecycleMutation` via an `onSuccessExtra` callback. The retry path now skips invalidating the source run's `detail`/`billing` caches (which retry doesn't change).\n- Folded `handleRetryResult` + `lastRetryResultRef` into the existing `handleLifecycleToastResult` + `lifecycleToastStateRef` with an optional `navigate` parameter. The parallel toast-state machine is gone.\n- Updated the matching test to use the unified handler.\n\n**Verified:** `cargo check`, `cargo nextest run -p fabro-workflow operations::retry`, `cargo nextest run -p fabro-server retry` (4 + 9 tests pass), `cargo clippy --workspace --all-targets -D warnings`, `cargo fmt`, `bun run typecheck`. The 8 web test failures present when running all files together exist on the baseline (HEAD before my changes), so they're pre-existing and unrelated.\n\n**Findings I deliberately skipped** (out of scope or low ROI for a retry-focused review):\n- Extracting a shared `RunCreated`-from-`RunSpec` builder between `fork.rs` and `retry.rs` (broader refactor).\n- Fixing `fork.rs`/`rewind.rs` to use `map_open_run_error` (pre-existing bugs, not introduced here).\n- Moving retry test fixtures into a shared `test_support` module.\n- Auditing the `Box::pin(...)` additions in server tests.\n- The `pt-3` → `pt-3.5` test-only fix that snuck into this commit.\n- Normalizing the `canRetry` signature to match `canCancel`/`canArchive` — the divergence is justified (needs failure reason + archived flag).", + "internal.thread_id": "implement", + "thread.toolchain.current_node": "preflight_compile", + "thread.preflight_compile.current_node": "preflight_lint", + "failure_signature": "", + "last_stage": "simplify_opus", + "internal.retry_count.preflight_lint": 0, "response.implement": "Implemented manual run retry end-to-end.\n\nSummary:\n- Added `retried_from` to:\n - `Run`\n - `RunProjection`\n - `run.created` event props\n - OpenAPI `Run` / `RunProjection`\n - generated TS API client models\n- Added workflow retry operation:\n - Creates a fresh run store\n - Copies durable definition/settings/source/git/labels/title/current parent/fork source/blob refs\n - Sets new `created_by` via provenance\n - Sets `retried_from`\n - Does not copy checkpoints/runtime/sandbox runtime/PR/conclusion/questions/pending controls\n - Rejects succeeded, active, cancelled, archived, and missing runs\n- Added `POST /api/v1/runs/{id}/retry`:\n - Returns `201` with the new queued `Run`\n - Queues via the same internal start path used by `POST /runs/{id}/start`\n - Leaves source run unchanged\n- Updated web UI:\n - Added `Retry` action for eligible failed/dead runs, hidden in demo mode\n - Disables while pending\n - Calls generated client, refreshes caches, and navigates to the new run on success\n - Shows retry errors via toast\n - Adds compact `Retried from` link in run summary panel\n- Regenerated Rust API and TypeScript Axios client.\n\nValidation run:\n- `cargo build -q -p fabro-api`\n- `cargo check -q --workspace`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings`\n- `cargo test -q -p fabro-api`\n- `cargo test -q -p fabro-store legacy_run_created_projects_retried_from_none --lib`\n- `cargo test -q -p fabro-workflow retry_ --lib`\n- `cargo test -q -p fabro-server retry_ --lib`\n- `cd apps/fabro-web && bun run typecheck`\n- `cd apps/fabro-web && bun test app/lib/run-actions.test.ts app/components/run-summary-panel.test.tsx app/routes/run-detail.test.ts`\n\nNote: I also attempted the full `cd apps/fabro-web && bun test`; it still reports unrelated test-isolation failures around shared `generatedAxios.defaults.adapter` state plus an existing run-detail dock assertion, while the focused retry/UI tests above pass.", "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", "graph.rankdir": "LR", - "internal.retry_count.preflight_compile": 0, - "internal.fidelity": "compact", + "thread.implement.current_node": "simplify_opus", "failure_class": "", - "current_node": "implement", - "thread.start.current_node": "toolchain", - "internal.retry_count.toolchain": 0, + "current_node": "simplify_opus", "thread.preflight_lint.current_node": "implement", - "last_response": "Implemented manual run retry end-to-end.\n\nSummary:\n- Added `retried_from` to:\n - `Run`\n - `RunProjection`\n - `run.created` event props\n - OpenAPI `Run` / `RunProjection`\n - generated TS API clien", - "outcome": "succeeded", - "thread.toolchain.current_node": "preflight_compile" + "last_response": "Net 32-line reduction. Here's the summary of fixes applied:\n\n## Summary\n\nThree review agents flagged duplication and clone churn in the retry feature. I applied these focused fixes:\n\n**Rust (`retry.rs" }, "node_outcomes": { "start": { @@ -869,15 +995,56 @@ }, "notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", "usage": null + }, + "simplify_opus": { + "status": "succeeded", + "context_updates": { + "last_response": "Net 32-line reduction. Here's the summary of fixes applied:\n\n## Summary\n\nThree review agents flagged duplication and clone churn in the retry feature. I applied these focused fixes:\n\n**Rust (`retry.rs", + "response.simplify_opus": "Net 32-line reduction. Here's the summary of fixes applied:\n\n## Summary\n\nThree review agents flagged duplication and clone churn in the retry feature. I applied these focused fixes:\n\n**Rust (`retry.rs`, `lifecycle.rs`):**\n- Removed dead writes (`spec.run_id = ...`, `spec.provenance = ...`) and double clones of every spec field. Destructured `source.spec` by move so each field flows directly into the `RunCreated` event without an intermediate `RunSpec` clone.\n- Changed `RetryRunInput::new_run_id` from `Option` to required `RunId` — the handler always pre-generates one to compute `web_url`; the operation no longer carries a phantom default.\n\n**Frontend (`run-actions.ts`, `mutations.ts`, `run-detail.tsx`):**\n- Added `\"retry\"` to the `LifecycleAction` union and folded `retryErrorMessage` into `mapError` (both had identical 404 + detail + fallback shape).\n- Dropped `RetryMutationResult` — now uses the existing `LifecycleMutationResult` with `intent: \"retry\"`.\n- Folded `useRetryRun` into `useLifecycleMutation` via an `onSuccessExtra` callback. The retry path now skips invalidating the source run's `detail`/`billing` caches (which retry doesn't change).\n- Folded `handleRetryResult` + `lastRetryResultRef` into the existing `handleLifecycleToastResult` + `lifecycleToastStateRef` with an optional `navigate` parameter. The parallel toast-state machine is gone.\n- Updated the matching test to use the unified handler.\n\n**Verified:** `cargo check`, `cargo nextest run -p fabro-workflow operations::retry`, `cargo nextest run -p fabro-server retry` (4 + 9 tests pass), `cargo clippy --workspace --all-targets -D warnings`, `cargo fmt`, `bun run typecheck`. The 8 web test failures present when running all files together exist on the baseline (HEAD before my changes), so they're pre-existing and unrelated.\n\n**Findings I deliberately skipped** (out of scope or low ROI for a retry-focused review):\n- Extracting a shared `RunCreated`-from-`RunSpec` builder between `fork.rs` and `retry.rs` (broader refactor).\n- Fixing `fork.rs`/`rewind.rs` to use `map_open_run_error` (pre-existing bugs, not introduced here).\n- Moving retry test fixtures into a shared `test_support` module.\n- Auditing the `Box::pin(...)` additions in server tests.\n- The `pt-3` → `pt-3.5` test-only fix that snuck into this commit.\n- Normalizing the `canRetry` signature to match `canCancel`/`canArchive` — the divergence is justified (needs failure reason + archived flag).", + "last_stage": "simplify_opus" + }, + "notes": "Stage completed: simplify_opus", + "usage": { + "input": { + "usage": { + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, + "tokens": { + "input_tokens": 194045, + "output_tokens": 33010, + "reasoning_tokens": 0, + "cache_read_tokens": 12537182, + "cache_write_tokens": 1023105 + } + }, + "facts": { + "algorithm": "anthropic", + "cache_write_5m_tokens": 1023105, + "cache_write_1h_tokens": 0 + } + }, + "total_usd_micros": 14458472 + }, + "files_touched": [ + "/home/daytona/workspace/fabro/apps/fabro-web/app/lib/mutations.ts", + "/home/daytona/workspace/fabro/apps/fabro-web/app/lib/run-actions.ts", + "/home/daytona/workspace/fabro/apps/fabro-web/app/routes/run-detail.test.ts", + "/home/daytona/workspace/fabro/apps/fabro-web/app/routes/run-detail.tsx", + "/home/daytona/workspace/fabro/lib/crates/fabro-server/src/server/handler/lifecycle.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/operations/retry.rs" + ] } }, - "next_node_id": "simplify_opus", + "next_node_id": "simplify_gpt", "node_visits": { "implement": 1, "start": 1, "toolchain": 1, "preflight_compile": 1, - "preflight_lint": 1 + "preflight_lint": 1, + "simplify_opus": 1 } }, "diff": {} @@ -904,6 +1071,44 @@ "superseded_by": null, "pending_interviews": {}, "todos_by_list": { + "anthropic_tasks:44fd9ba0-8ea2-46be-b51d-bff85a50174f": { + "kind": "anthropic_tasks", + "list_id": "anthropic_tasks:44fd9ba0-8ea2-46be-b51d-bff85a50174f", + "items": [ + { + "id": "1", + "status": "completed", + "order": 0, + "subject": "Clean up retry.rs spec clone churn", + "description": "Drop dead writes (spec.run_id, spec.provenance) and redundant per-field clones. Destructure source.spec by move.", + "active_form": "Cleaning up retry.rs spec clone churn" + }, + { + "id": "2", + "status": "completed", + "order": 1, + "subject": "Drop RetryRunInput::new_run_id Option", + "description": "Make new_run_id required RunId in RetryRunInput rather than Option. Eliminates the dead-code fallback.", + "active_form": "Dropping RetryRunInput::new_run_id Option" + }, + { + "id": "3", + "status": "completed", + "order": 2, + "subject": "Unify retry into LifecycleAction on frontend", + "description": "Add \"retry\" to LifecycleAction, fold useRetryRun into useLifecycleMutation (with onSuccess extension), fold retryErrorMessage into mapError, fold handleRetryResult into handleLifecycleToastResult, drop RetryMutationResult.", + "active_form": "Unifying retry into LifecycleAction on frontend" + }, + { + "id": "4", + "status": "completed", + "order": 3, + "subject": "Drop redundant source-run cache invalidation in retry mutation", + "description": "Remove mutate(queryKeys.runs.detail(id)) for the source run, which retry doesn't mutate.", + "active_form": "Dropping redundant source-run cache invalidation" + } + ] + }, "openai_plan:cecb4510-1e92-4fc6-951f-4d2dd388df2c": { "kind": "openai_plan", "list_id": "openai_plan:cecb4510-1e92-4fc6-951f-4d2dd388df2c", @@ -1096,7 +1301,12 @@ "first_event_seq": 51, "prompt": null, "response": null, - "completion": null, + "completion": { + "outcome": "succeeded", + "notes": "Stage completed: implement", + "failure_reason": null, + "timestamp": "2026-05-23T10:43:19.416565Z" + }, "provider_used": { "mode": "agent", "provider": "openai", @@ -1109,6 +1319,12 @@ "output": null, "started_at": "2026-05-23T10:06:27.782388Z", "handler": "agent", + "timing": { + "wall_time_ms": 2211617, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, "usage": { "input_tokens": 463085, "output_tokens": 38427, @@ -1122,6 +1338,38 @@ "provider": "openai", "model_id": "gpt-5.5" }, + "state": "succeeded" + }, + "simplify_opus@1": { + "first_event_seq": 987, + "prompt": null, + "response": null, + "completion": null, + "provider_used": { + "mode": "agent", + "provider": "anthropic", + "model": "claude-opus-4-7" + }, + "diff": null, + "script_invocation": null, + "script_timing": null, + "parallel_results": null, + "output": null, + "started_at": "2026-05-23T10:43:26.147478Z", + "handler": "agent", + "usage": { + "input_tokens": 194045, + "output_tokens": 33010, + "total_tokens": 13787342, + "reasoning_tokens": 0, + "cache_read_tokens": 12537182, + "cache_write_tokens": 1023105, + "total_usd_micros": 14458472 + }, + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, "state": "running" }, "start@1": { diff --git a/stages/005-implement@1/diff.patch b/stages/005-implement@1/diff.patch new file mode 100644 index 000000000..6f2dcb549 --- /dev/null +++ b/stages/005-implement@1/diff.patch @@ -0,0 +1,2361 @@ +diff --git a/apps/fabro-web/app/components/run-summary-panel.test.tsx b/apps/fabro-web/app/components/run-summary-panel.test.tsx +index 05cabf2d3..881026730 100644 +--- a/apps/fabro-web/app/components/run-summary-panel.test.tsx ++++ b/apps/fabro-web/app/components/run-summary-panel.test.tsx +@@ -1,5 +1,6 @@ + import { describe, expect, test } from "bun:test"; + import TestRenderer, { act } from "react-test-renderer"; ++import { MemoryRouter } from "react-router"; + + import { + RunSummaryPanelView, +@@ -138,6 +139,29 @@ describe("RunSummaryPanelView", () => { + expect(instanceText(cellAfterLabel(tree, "Artifacts"))).toBe("3"); + }); + ++ test("renders Retried from link when present", () => { ++ let tree: TestRenderer.ReactTestRenderer | undefined; ++ act(() => { ++ tree = TestRenderer.create( ++ ++ ++ , ++ ); ++ }); ++ const cell = cellAfterLabel(tree!, "Retried from"); ++ const link = cell.find((node) => node.type === "a"); ++ expect(link.props.href).toBe("/runs/01KRETRYFROMRUNID"); ++ expect(instanceText(link)).toBe("01KRETRY"); ++ }); ++ + test("renders user actor with login initial", () => { + const tree = render({ + run: makeRun({ +diff --git a/apps/fabro-web/app/components/run-summary-panel.tsx b/apps/fabro-web/app/components/run-summary-panel.tsx +index c5004105f..30797329d 100644 +--- a/apps/fabro-web/app/components/run-summary-panel.tsx ++++ b/apps/fabro-web/app/components/run-summary-panel.tsx +@@ -13,6 +13,7 @@ import type { + SandboxResources, + SandboxState, + } from "@qltysh/fabro-api-client"; ++import { Link } from "react-router"; + + import { + formatBytesAsMemory, +@@ -223,6 +224,17 @@ export function RunSummaryPanelView({ + + )} + ++ ++ {run?.retried_from && ( ++ ++ ++ {run.retried_from.slice(0, 8)} ++ ++ ++ )} +
+
+ ); +diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts +index 8cbfdf661..2d6a750a2 100644 +--- a/apps/fabro-web/app/data/runs.test.ts ++++ b/apps/fabro-web/app/data/runs.test.ts +@@ -48,6 +48,7 @@ function makeRun(overrides: Partial = {}): Run { + pull_request: null, + current_question: null, + superseded_by: null, ++ retried_from: null, + links: { web: null }, + ...overrides, + }; +@@ -180,4 +181,4 @@ describe("columnForStatus", () => { + test("returns null for lifecycle states that do not map to a board column", () => { + expect(columnForStatus("removing")).toBeNull(); + }); +-}); +\ No newline at end of file ++}); +diff --git a/apps/fabro-web/app/lib/mutations.ts b/apps/fabro-web/app/lib/mutations.ts +index 889245bba..1c7ad0ef5 100644 +--- a/apps/fabro-web/app/lib/mutations.ts ++++ b/apps/fabro-web/app/lib/mutations.ts +@@ -21,6 +21,7 @@ import { + archiveRun, + cancelRun, + isLifecycleActionError, ++ retryRun, + unarchiveRun, + } from "./run-actions"; + +@@ -47,6 +48,18 @@ export type LifecycleMutationResult = + error: LifecycleActionError | null; + }; + ++export type RetryMutationResult = ++ | { ++ intent: "retry"; ++ ok: true; ++ run: Run; ++ } ++ | { ++ intent: "retry"; ++ ok: false; ++ error: LifecycleActionError | null; ++ }; ++ + export function usePreviewRun(id: string | undefined) { + return useSWRMutation( + id ? queryKeys.runs.preview(id) : null, +@@ -71,6 +84,38 @@ export function useUnarchiveRun(id: string | undefined) { + return useLifecycleMutation(id, "unarchive", unarchiveRun); + } + ++export function useRetryRun(id: string | undefined) { ++ const { mutate } = useSWRConfig(); ++ return useSWRMutation( ++ id ? queryKeys.runs.retry(id) : null, ++ async (): Promise => { ++ if (!id) { ++ return { intent: "retry", ok: false, error: null }; ++ } ++ try { ++ return { intent: "retry", ok: true, run: await retryRun(id) }; ++ } catch (error) { ++ return { ++ intent: "retry", ++ ok: false, ++ error: isLifecycleActionError(error) ? error : null, ++ }; ++ } ++ }, ++ { ++ onSuccess: (result) => { ++ if (!id || !result.ok) return; ++ void mutate(queryKeys.runs.detail(id)); ++ void mutate(queryKeys.runs.detail(result.run.id), result.run, { revalidate: false }); ++ if (result.run.parent_id) { ++ void mutate(queryKeys.runs.children(result.run.parent_id)); ++ } ++ mutateBoardRunCaches(mutate); ++ }, ++ }, ++ ); ++} ++ + function useLifecycleMutation( + id: string | undefined, + intent: LifecycleAction, +diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts +index c0d323e82..6034d9884 100644 +--- a/apps/fabro-web/app/lib/query-keys.ts ++++ b/apps/fabro-web/app/lib/query-keys.ts +@@ -77,6 +77,7 @@ export const queryKeys = { + pullRequest: (id: string) => ["runs", "pull-request", id] as const, + preview: (id: string) => ["runs", "preview", id] as const, + cancel: (id: string) => ["runs", "cancel", id] as const, ++ retry: (id: string) => ["runs", "retry", id] as const, + archive: (id: string) => ["runs", "archive", id] as const, + unarchive: (id: string) => ["runs", "unarchive", id] as const, + updateTitle: (id: string) => ["runs", "update-title", id] as const, +diff --git a/apps/fabro-web/app/lib/run-actions.test.ts b/apps/fabro-web/app/lib/run-actions.test.ts +index 8a8fb9d9d..5e61a9587 100644 +--- a/apps/fabro-web/app/lib/run-actions.test.ts ++++ b/apps/fabro-web/app/lib/run-actions.test.ts +@@ -6,10 +6,12 @@ import { + archiveRun, + canArchive, + canCancel, ++ canRetry, + canUnarchive, + cancelRun, + isTerminalCancelledRun, + mapError, ++ retryRun, + unarchiveRun, + } from "./run-actions"; + import { generatedAxios } from "./api-client"; +@@ -55,6 +57,7 @@ function makeRun(status: RunStatus, archived = false): Run { + pull_request: null, + current_question: null, + superseded_by: null, ++ retried_from: null, + links: { web: null }, + }; + } +@@ -135,6 +138,22 @@ describe("run lifecycle actions", () => { + expect(result.lifecycle.archived).toBe(false); + }); + ++ test("retryRun parses a 201 response", async () => { ++ stubGeneratedAxiosOnce({ ++ status: 201, ++ body: { ++ ...makeRun({ kind: "queued" }), ++ id: "run-2", ++ retried_from: "run-1", ++ }, ++ }); ++ ++ const result = await retryRun("run-1"); ++ expect(result.id).toBe("run-2"); ++ expect(result.retried_from).toBe("run-1"); ++ expect(result.lifecycle.status.kind).toBe("queued"); ++ }); ++ + test("404 and 409 preserve the parsed error envelope", async () => { + stubGeneratedAxiosOnce({ + status: 404, +@@ -196,6 +215,15 @@ describe("run lifecycle actions", () => { + expect(canUnarchive("failed")).toBe(false); + }); + ++ test("canRetry allows failed and dead runs except cancelled or archived runs", () => { ++ expect(canRetry(makeRun({ kind: "failed", reason: "workflow_error" }))).toBe(true); ++ expect(canRetry(makeRun({ kind: "dead" }))).toBe(true); ++ expect(canRetry(makeRun({ kind: "failed", reason: "cancelled" }))).toBe(false); ++ expect(canRetry(makeRun({ kind: "succeeded", reason: "completed" }))).toBe(false); ++ expect(canRetry(makeRun({ kind: "running" }))).toBe(false); ++ expect(canRetry(makeRun({ kind: "failed", reason: "workflow_error" }, true))).toBe(false); ++ }); ++ + test("isTerminalCancelledRun distinguishes immediate cancel success from in-flight cancellation", () => { + expect( + isTerminalCancelledRun(makeRun({ kind: "failed", reason: "cancelled" })), +diff --git a/apps/fabro-web/app/lib/run-actions.ts b/apps/fabro-web/app/lib/run-actions.ts +index ae3126d3a..cf8468398 100644 +--- a/apps/fabro-web/app/lib/run-actions.ts ++++ b/apps/fabro-web/app/lib/run-actions.ts +@@ -43,6 +43,14 @@ export async function unarchiveRun(id: string, request?: Request): Promise + return runLifecycleAction(id, "unarchive", request); + } + ++export async function retryRun(id: string, request?: Request): Promise { ++ try { ++ return await apiData(() => runsApi.retryRun(id, requestSignalOptions(request))); ++ } catch (error) { ++ throw lifecycleActionErrorFromError(error); ++ } ++} ++ + export async function deleteRun(id: string, request?: Request): Promise { + try { + await apiResponse(() => runsApi.deleteRun(id, undefined, requestSignalOptions(request))); +@@ -64,6 +72,13 @@ export function canUnarchive(status: string | null | undefined): boolean { + return status === "archived"; + } + ++export function canRetry(run: Pick | null | undefined): boolean { ++ if (!run || run.lifecycle.archived) return false; ++ const status = run.lifecycle.status; ++ if (status.kind === "dead") return true; ++ return status.kind === "failed" && status.reason !== "cancelled"; ++} ++ + export function canDelete(status: string | null | undefined): boolean { + return status === "archived"; + } +@@ -84,6 +99,20 @@ export function deleteErrorMessage(error: unknown): string { + return "Couldn't delete the run right now. Try again."; + } + ++export function retryErrorMessage(error: unknown): string { ++ if (isLifecycleActionError(error)) { ++ if (error.status === 404) { ++ return "This run no longer exists."; ++ } ++ if (error.status === 409) { ++ return "This run can no longer be retried."; ++ } ++ const detail = error.errors[0]?.detail?.trim(); ++ if (detail) return detail; ++ } ++ return "Couldn't retry the run right now. Try again."; ++} ++ + export function mapError(error: unknown, action: LifecycleAction): string { + if (isLifecycleActionError(error)) { + if (error.status === 404) { +diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts +index a68b4a678..3c55988ba 100644 +--- a/apps/fabro-web/app/routes/run-detail.test.ts ++++ b/apps/fabro-web/app/routes/run-detail.test.ts +@@ -53,6 +53,7 @@ const { + default: RunDetail, + focusSteerAfterMenuClose, + handleLifecycleToastResult, ++ handleRetryResult, + lifecycleActionVisibility, + } = await import("./run-detail"); + mock.restore(); +@@ -111,6 +112,7 @@ function makeRunSummary( + pull_request: pullRequest, + current_question: null, + superseded_by: null, ++ retried_from: null, + links: { web: null }, + }; + } +@@ -382,7 +384,7 @@ describe("RunDetail full-height child routes", () => { + const outletWrappers = renderer.root.findAll( + (node) => + node.type === "div" && +- hasClasses(node.props.className, ["pt-3", "min-h-0", "flex-1"]), ++ hasClasses(node.props.className, ["pt-3.5", "min-h-0", "flex-1"]), + ); + expect(outletWrappers).toHaveLength(1); + }); +@@ -401,6 +403,50 @@ describe("RunDetail full-height child routes", () => { + expect(badges.map((badge) => badge.children.join(""))).toContain("7"); + }); + ++ test("successful retry result navigates to the new run once", () => { ++ const pushed: Array<{ message: string; tone?: string }> = []; ++ const navigated: string[] = []; ++ const result: RetryMutationResult = { ++ intent: "retry", ++ ok: true, ++ run: { ++ ...makeRunSummary("queued"), ++ id: "run_retry", ++ retried_from: "run_1", ++ }, ++ }; ++ ++ const next = handleRetryResult( ++ result, ++ null, ++ { ++ push: (toast) => { ++ pushed.push(toast); ++ return "toast-1"; ++ }, ++ dismiss: () => undefined, ++ }, ++ (path) => navigated.push(path), ++ ); ++ const replay = handleRetryResult( ++ result, ++ next, ++ { ++ push: (toast) => { ++ pushed.push(toast); ++ return "toast-2"; ++ }, ++ dismiss: () => undefined, ++ }, ++ (path) => navigated.push(path), ++ ); ++ ++ expect(next).toBe(result); ++ expect(replay).toBe(result); ++ expect(pushed).toEqual([{ message: "Retry started." }]); ++ expect(navigated).toEqual(["/runs/run_retry"]); ++ }); ++ + test("shows the Sandbox tab when the run has a sandbox", async () => { + currentRunState = { sandbox: { provider: "docker", id: "container-1" } }; + const renderer = await renderRunDetail({ +@@ -541,10 +587,10 @@ describe("RunDetail full-height child routes", () => { + (node) => + node.type === "div" && + hasClasses(node.props.className, [ +- "pt-3", ++ "pt-3.5", + "pb-[var(--fabro-interview-dock-clearance)]", + ]), + ); + expect(outletWrappers).toHaveLength(1); + }); +-}); +\ No newline at end of file ++}); +diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx +index 91e912ea9..a9922a074 100644 +--- a/apps/fabro-web/app/routes/run-detail.tsx ++++ b/apps/fabro-web/app/routes/run-detail.tsx +@@ -59,9 +59,11 @@ import { + useCancelRun, + useInterruptRun, + usePreviewRun, ++ useRetryRun, + useUnarchiveRun, + type LifecycleMutationResult, + type PreviewMutationResult, ++ type RetryMutationResult, + } from "../lib/mutations"; + import { formatAbsoluteTs, formatRelativeTime } from "../lib/format"; + import { queryKeys } from "../lib/query-keys"; +@@ -72,11 +74,13 @@ import { + canArchive, + canCancel, + canDelete, ++ canRetry, + canUnarchive, + deleteErrorMessage, + deleteRun, + isTerminalCancelledRun, + mapError, ++ retryErrorMessage, + type LifecycleAction, + type LifecycleActionError, + } from "../lib/run-actions"; +@@ -382,6 +386,7 @@ export default function RunDetail({ params }: { params: { id: string } }) { + const cancelMutation = useCancelRun(params.id); + const archiveMutation = useArchiveRun(params.id); + const unarchiveMutation = useUnarchiveRun(params.id); ++ const retryMutation = useRetryRun(params.id); + const interruptMutation = useInterruptRun(params.id); + const navigate = useNavigate(); + const { mutate } = useSWRConfig(); +@@ -399,6 +404,7 @@ export default function RunDetail({ params }: { params: { id: string } }) { + }) + .filter((t) => (!t.demoOnly || demoMode) && (!t.requiresSandbox || hasSandbox)); + const lifecycleToastStateRef = useRef(INITIAL_LIFECYCLE_TOAST_STATE); ++ const lastRetryResultRef = useRef(null); + const steerBarRef = useRef(null); + const now = useTickingNow(30_000); + const fullHeight = matches.some( +@@ -448,6 +454,15 @@ export default function RunDetail({ params }: { params: { id: string } }) { + ); + }, [dismiss, push, unarchiveMutation.data]); + ++ useEffect(() => { ++ lastRetryResultRef.current = handleRetryResult( ++ retryMutation.data, ++ lastRetryResultRef.current, ++ { push, dismiss }, ++ navigate, ++ ); ++ }, [dismiss, navigate, push, retryMutation.data]); ++ + if (runQuery.isLoading && !run) { + return
; + } +@@ -495,6 +510,7 @@ export default function RunDetail({ params }: { params: { id: string } }) { + const cancelPending = cancelMutation.isMutating; + const archivePending = archiveMutation.isMutating; + const unarchivePending = unarchiveMutation.isMutating; ++ const retryPending = retryMutation.isMutating; + const handleConfirmDelete = async () => { + setDeletePending(true); + try { +@@ -630,6 +646,9 @@ export default function RunDetail({ params }: { params: { id: string } }) { + canArchive={visibility.showArchive} + archivePending={archivePending} + onArchive={() => void archiveMutation.trigger()} ++ canRetry={!demoMode && canRetry(summary)} ++ retryPending={retryPending} ++ onRetry={() => void retryMutation.trigger()} + canUnarchive={visibility.showUnarchive} + unarchivePending={unarchivePending} + onUnarchive={() => void unarchiveMutation.trigger()} +@@ -833,6 +852,22 @@ export function handleLifecycleToastResult( + return { ...nextState, activeArchiveToastId: null }; + } + ++export function handleRetryResult( ++ result: RetryMutationResult | undefined, ++ lastProcessed: RetryMutationResult | null, ++ toastApi: ToastApi, ++ navigate: (path: string) => void, ++): RetryMutationResult | null { ++ if (!result || lastProcessed === result) return lastProcessed; ++ if (result.ok === true) { ++ toastApi.push({ message: "Retry started." }); ++ navigate(`/runs/${result.run.id}`); ++ } else { ++ toastApi.push({ message: retryErrorMessage(result.error), tone: "error" }); ++ } ++ return result; ++} ++ + function ConnectMenu() { + return ( + +@@ -872,6 +907,9 @@ interface ActionsMenuProps { + canArchive: boolean; + archivePending: boolean; + onArchive: () => void; ++ canRetry: boolean; ++ retryPending: boolean; ++ onRetry: () => void; + canUnarchive: boolean; + unarchivePending: boolean; + onUnarchive: () => void; +@@ -889,6 +927,7 @@ function ActionsMenu(props: ActionsMenuProps) { + canFocusSteer, onFocusSteer, + canPreview, previewPending, onPreview, + canArchive, archivePending, onArchive, ++ canRetry, retryPending, onRetry, + canUnarchive, unarchivePending, onUnarchive, + canDelete, deletePending, onDelete, + canCancel, cancelPending, onCancel, +@@ -896,11 +935,11 @@ function ActionsMenu(props: ActionsMenuProps) { + + const hasOps = + canPreview || canSendInterrupt || canFocusSteer; +- const hasLifecycle = canArchive || canUnarchive; ++ const hasLifecycle = canRetry || canArchive || canUnarchive; + const hasDestructive = canCancel || canDelete; + const hasAny = hasOps || hasLifecycle || hasDestructive; + const anyPending = +- previewPending || archivePending || unarchivePending || deletePending || cancelPending || interruptPending; ++ previewPending || retryPending || archivePending || unarchivePending || deletePending || cancelPending || interruptPending; + const separators = actionMenuSeparatorVisibility({ hasLifecycle, hasDestructive }); + + if (!hasAny) return null; +@@ -952,6 +991,18 @@ function ActionsMenu(props: ActionsMenuProps) { + {separators.afterOperations && ( +
+ )} ++ {canRetry && ( ++ ++ ++ ++ )} + {canArchive && ( + +