authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-03 20:00:22-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 20:00:22-07:00
log737993303d36e10674c54b95e5bd3826682c99c7
treee626212a0f9d26797f88436a58ee06eaf3966add
parent1826fb55cc4c9c6246d31175e15a496ba4778e07
signature Signed by PGP key B5690EEEBB952194

feat: retry a failed workspace preparation (#15326)


13 files changed, 752 insertions(+), 70 deletions(-)

apps/mobile/src/features/threads/thread-work-log.tsx+57-1
...@@ -41,7 +41,17 @@ import {...@@ -41,7 +41,17 @@ import {
41 View,41 View,
42} from "react-native";42} from "react-native";
43import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";43import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
44import type { EnvironmentId, ToolActivityIcon } from "@t3tools/contracts";44import {
45 ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE,
46 type EnvironmentId,
47 type RunId,
48 type ThreadId,
49 type ToolActivityIcon,
50} from "@t3tools/contracts";
51import { useAtomValue } from "@effect/atom-react";
52import { scopeThreadRef } from "@t3tools/client-runtime/environment";
53import { environmentThreadDetails, threadEnvironment } from "../../state/threads";
54import { useAtomCommand } from "../../state/use-atom-command";
45import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";55import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
4656
47import { AppText as Text } from "../../components/AppText";57import { AppText as Text } from "../../components/AppText";
...@@ -770,6 +780,44 @@ function workLogRowKey(row: ThreadFeedActivity): string {...@@ -770,6 +780,44 @@ function workLogRowKey(row: ThreadFeedActivity): string {
770 return row.id;780 return row.id;
771}781}
772782
783/** Shown while the thread's run still ends in this failed preparation. */
784function WorkspacePreparationRetryButton(props: {
785 readonly environmentId: EnvironmentId;
786 readonly threadId: ThreadId;
787 readonly runId: RunId;
788}) {
789 const retryable = useAtomValue(
790 environmentThreadDetails.threadAtom(scopeThreadRef(props.environmentId, props.threadId)),
791 (thread) => {
792 const run = thread?.projection.runs.find((candidate) => candidate.id === props.runId);
793 return run?.status === "failed" && run.workspacePreparation !== undefined;
794 },
795 );
796 const retry = useAtomCommand(threadEnvironment.retryWorkspacePreparation, "retry setup");
797 const [busy, setBusy] = useState(false);
798 if (!retryable) return null;
799 return (
800 <Pressable
801 accessibilityRole="button"
802 accessibilityLabel="Retry workspace preparation"
803 disabled={busy}
804 onPress={() => {
805 setBusy(true);
806 void Haptics.selectionAsync();
807 void retry({
808 environmentId: props.environmentId,
809 input: { threadId: props.threadId, runId: props.runId },
810 }).finally(() => setBusy(false));
811 }}
812 className="ml-7 mt-2 min-h-11 flex-row items-center gap-1.5 self-start rounded-full border border-border px-4"
813 style={{ opacity: busy ? 0.5 : 1 }}
814 >
815 <SymbolView name="arrow.clockwise" size={13} tintColorClassName="accent-icon" />
816 <Text className="font-t3-medium text-sm text-foreground">Retry</Text>
817 </Pressable>
818 );
819}
820
773const ThreadWorkLogRow = memo(function ThreadWorkLogRow(821const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
774 props: Omit<822 props: Omit<
775 ThreadWorkLogProps,823 ThreadWorkLogProps,
...@@ -850,6 +898,14 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(...@@ -850,6 +898,14 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
850 {failureItem.failure.message}898 {failureItem.failure.message}
851 </Text>899 </Text>
852 ) : null}900 ) : null}
901 {failureItem.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE &&
902 failureItem.runId !== null ? (
903 <WorkspacePreparationRetryButton
904 environmentId={props.environmentId}
905 threadId={failureItem.threadId}
906 runId={failureItem.runId}
907 />
908 ) : null}
853 </View>909 </View>
854 </WorkLogPressable>910 </WorkLogPressable>
855 );911 );
apps/server/src/orchestration-v2/Orchestrator.ts+110-3
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 legacyThreadPullRequestKey,12 legacyThreadPullRequestKey,
13} from "@t3tools/shared/threadPullRequests";13} from "@t3tools/shared/threadPullRequests";
14import {14import {
15 ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE,
15 type ChatAttachment,16 type ChatAttachment,
16 CommandId,17 CommandId,
17 isProviderNativeSubagentThread,18 isProviderNativeSubagentThread,
...@@ -400,6 +401,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {...@@ -400,6 +401,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {
400 case "prepared-run.release":401 case "prepared-run.release":
401 case "prepared-run.progress":402 case "prepared-run.progress":
402 case "prepared-run.fail":403 case "prepared-run.fail":
404 case "prepared-run.retry":
403 case "run.interrupt":405 case "run.interrupt":
404 case "queued-message.promote-to-steer":406 case "queued-message.promote-to-steer":
405 case "queue.resume":407 case "queue.resume":
...@@ -442,6 +444,14 @@ function pendingThreadTitleGenerationEffect(...@@ -442,6 +444,14 @@ function pendingThreadTitleGenerationEffect(
442444
443const WORKSPACE_PREPARATION_INPUT = "Preparing workspace";445const WORKSPACE_PREPARATION_INPUT = "Preparing workspace";
444446
447/** A reopened preparation item drops the output and exit code of the attempt it replaces. */
448function withoutPreparationResult(
449 item: Extract<OrchestrationV2TurnItem, { readonly type: "command_execution" }>,
450) {
451 const { output: _output, exitCode: _exitCode, outputIndicatesFailure: _failure, ...rest } = item;
452 return rest;
453}
454
445function isBlockingRun(run: OrchestrationV2Run): boolean {455function isBlockingRun(run: OrchestrationV2Run): boolean {
446 return (456 return (
447 run.status === "preparing" ||457 run.status === "preparing" ||
...@@ -5150,6 +5160,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -5150,6 +5160,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
5150 ...(command.restartContinuationOfRunId === undefined5160 ...(command.restartContinuationOfRunId === undefined
5151 ? {}5161 ? {}
5152 : { restartContinuationOfRunId: command.restartContinuationOfRunId }),5162 : { restartContinuationOfRunId: command.restartContinuationOfRunId }),
5163 ...(dispatchMode.type === "defer_start" && dispatchMode.workspaceStrategy !== undefined
5164 ? { workspacePreparation: dispatchMode.workspaceStrategy }
5165 : {}),
5153 ...wakeWorkStartedAt(projection.runs, command),5166 ...wakeWorkStartedAt(projection.runs, command),
5154 };5167 };
5155 const attempt: OrchestrationV2RunAttempt = {5168 const attempt: OrchestrationV2RunAttempt = {
...@@ -7489,7 +7502,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -7489,7 +7502,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
7489 command: Extract<7502 command: Extract<
7490 OrchestrationV2Command,7503 OrchestrationV2Command,
7491 {7504 {
7492 readonly type: "prepared-run.release" | "prepared-run.progress" | "prepared-run.fail";7505 readonly type:
7506 | "prepared-run.release"
7507 | "prepared-run.progress"
7508 | "prepared-run.fail"
7509 | "prepared-run.retry";
7493 }7510 }
7494 >,7511 >,
7495 projection: Pick<7512 projection: Pick<
...@@ -7512,7 +7529,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -7512,7 +7529,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
7512 candidate.input === WORKSPACE_PREPARATION_INPUT,7529 candidate.input === WORKSPACE_PREPARATION_INPUT,
7513 );7530 );
7514 if (7531 if (
7515 run?.status !== "preparing" ||7532 run?.status !== (command.type === "prepared-run.retry" ? "failed" : "preparing") ||
7516 attempt === undefined ||7533 attempt === undefined ||
7517 rootNode === undefined ||7534 rootNode === undefined ||
7518 providerThread === undefined ||7535 providerThread === undefined ||
...@@ -7730,7 +7747,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -7730,7 +7747,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
7730 completedAt: now,7747 completedAt: now,
7731 updatedAt: now,7748 updatedAt: now,
7732 type: "error",7749 type: "error",
7733 failure: command.failure,7750 failure: {
7751 ...command.failure,
7752 code: ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE,
7753 },
7734 },7754 },
7735 });7755 });
7736 yield* emitEvent({7756 yield* emitEvent({
...@@ -7744,6 +7764,90 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -7744,6 +7764,90 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
7744 });7764 });
7745 });7765 });
77467766
7767 /**
7768 * Returns a run whose workspace preparation failed to preparing. The failure
7769 * item turns cancelled so clients stop offering the retry; ThreadLaunchService
7770 * runs the recorded preparation again once this commits.
7771 */
7772 const dispatchPreparedRunRetry = (
7773 command: Extract<OrchestrationV2Command, { readonly type: "prepared-run.retry" }>,
7774 events: Ref.Ref<Array<OrchestrationV2DomainEvent>>,
7775 ) =>
7776 Effect.gen(function* () {
7777 const projection = yield* loadProjectionForCommand(
7778 command,
7779 ["runs", "attempts", "nodes", "providerThreads", "turnItems"],
7780 { turnItemTypes: ["command_execution", "error"], turnItemRunId: command.runId },
7781 );
7782 const state = preparedRunState(command, projection);
7783 const failureItem = projection.turnItems.find(
7784 (candidate): candidate is Extract<OrchestrationV2TurnItem, { readonly type: "error" }> =>
7785 candidate.type === "error" &&
7786 candidate.status === "failed" &&
7787 candidate.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE,
7788 );
7789 if (
7790 state === null ||
7791 state.run.workspacePreparation === undefined ||
7792 failureItem === undefined ||
7793 projection.thread.archivedAt !== null ||
7794 projection.thread.deletedAt !== null
7795 ) {
7796 return yield* new OrchestratorDispatchError({
7797 commandId: command.commandId,
7798 commandType: command.type,
7799 cause: `Run ${command.runId} has no failed workspace preparation to retry.`,
7800 });
7801 }
7802 if (projection.runs.some((run) => run.id !== state.run.id && isBlockingRun(run))) {
7803 return yield* new OrchestratorDispatchError({
7804 commandId: command.commandId,
7805 commandType: command.type,
7806 cause: "Another run is active on this thread.",
7807 });
7808 }
7809 const now = yield* DateTime.now;
7810 const emitEvent = emit(events, command);
7811 const scope = {
7812 threadId: command.threadId,
7813 runId: state.run.id,
7814 nodeId: state.rootNode.id,
7815 providerInstanceId: state.run.providerInstanceId,
7816 occurredAt: now,
7817 };
7818 yield* emitEvent({
7819 ...scope,
7820 type: "turn-item.updated",
7821 payload: { ...failureItem, status: "cancelled", updatedAt: now },
7822 });
7823 yield* emitEvent({
7824 ...scope,
7825 type: "turn-item.updated",
7826 payload: {
7827 ...withoutPreparationResult(state.preparationItem),
7828 status: "running",
7829 title: WORKSPACE_PREPARATION_INPUT,
7830 completedAt: null,
7831 updatedAt: now,
7832 },
7833 });
7834 yield* emitEvent({
7835 ...scope,
7836 type: "run-attempt.updated",
7837 payload: { ...state.attempt, status: "pending", completedAt: null },
7838 });
7839 yield* emitEvent({
7840 ...scope,
7841 type: "node.updated",
7842 payload: { ...state.rootNode, status: "pending", completedAt: null },
7843 });
7844 yield* emitEvent({
7845 ...scope,
7846 type: "run.updated",
7847 payload: { ...state.run, status: "preparing", completedAt: null },
7848 });
7849 });
7850
7747 /**7851 /**
7748 * Ends the background work a settled thread still shows that no provider7852 * Ends the background work a settled thread still shows that no provider
7749 * process will report on: work on the provider thread whose interrupt just7853 * process will report on: work on the provider thread whose interrupt just
...@@ -9450,6 +9554,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -9450,6 +9554,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
9450 case "prepared-run.fail":9554 case "prepared-run.fail":
9451 yield* dispatchPreparedRunFail(command, events);9555 yield* dispatchPreparedRunFail(command, events);
9452 break;9556 break;
9557 case "prepared-run.retry":
9558 yield* dispatchPreparedRunRetry(command, events);
9559 break;
9453 case "runtime-request.respond":9560 case "runtime-request.respond":
9454 yield* dispatchRuntimeRequestRespond(command, events, effects);9561 yield* dispatchRuntimeRequestRespond(command, events, effects);
9455 break;9562 break;
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+174-1
...@@ -10,6 +10,7 @@ import * as ThreadMessageIntake from "./ThreadMessageIntake.ts";...@@ -10,6 +10,7 @@ import * as ThreadMessageIntake from "./ThreadMessageIntake.ts";
10import { assert, it, vi } from "@effect/vitest";10import { assert, it, vi } from "@effect/vitest";
11import * as NodeCrypto from "@effect/platform-node/NodeCrypto";11import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
12import {12import {
13 ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE,
13 ChatAttachmentId,14 ChatAttachmentId,
14 ComposerContextId,15 ComposerContextId,
15 type ChatAttachment,16 type ChatAttachment,
...@@ -127,6 +128,10 @@ function makeHarness(options: HarnessOptions = {}) {...@@ -127,6 +128,10 @@ function makeHarness(options: HarnessOptions = {}) {
127 const renameBranch = vi.fn(128 const renameBranch = vi.fn(
128 options.renameBranch ?? ((input) => Effect.succeed({ branch: input.newBranch })),129 options.renameBranch ?? ((input) => Effect.succeed({ branch: input.newBranch })),
129 );130 );
131 const removeWorktree = vi.fn(
132 (_input: Parameters<GitWorkflow.GitWorkflowService["Service"]["removeWorktree"]>[0]) =>
133 Effect.void,
134 );
130 const runSetup = vi.fn(135 const runSetup = vi.fn(
131 options.runSetup ?? (() => Effect.succeed({ status: "no-script" as const })),136 options.runSetup ?? (() => Effect.succeed({ status: "no-script" as const })),
132 );137 );
...@@ -164,7 +169,7 @@ function makeHarness(options: HarnessOptions = {}) {...@@ -164,7 +169,7 @@ function makeHarness(options: HarnessOptions = {}) {
164 fetchRemote: options.fetchRemote ?? (() => Effect.void),169 fetchRemote: options.fetchRemote ?? (() => Effect.void),
165 remoteExists: () => Effect.succeed(true),170 remoteExists: () => Effect.succeed(true),
166 remoteBranchExists: () => Effect.succeed(true),171 remoteBranchExists: () => Effect.succeed(true),
167 removeWorktree: () => Effect.void,172 removeWorktree,
168 resolveRemoteTrackingCommit: () =>173 resolveRemoteTrackingCommit: () =>
169 Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" }),174 Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" }),
170 }),175 }),
...@@ -220,6 +225,7 @@ function makeHarness(options: HarnessOptions = {}) {...@@ -220,6 +225,7 @@ function makeHarness(options: HarnessOptions = {}) {
220 externalServices,225 externalServices,
221 ),226 ),
222 createWorktree,227 createWorktree,
228 removeWorktree,
223 renameBranch,229 renameBranch,
224 generateBranchName,230 generateBranchName,
225 generateThreadTitle,231 generateThreadTitle,
...@@ -1293,6 +1299,172 @@ it.effect("shows the fetch diagnosis when preparing a worktree from origin fails...@@ -1293,6 +1299,172 @@ it.effect("shows the fetch diagnosis when preparing a worktree from origin fails
1293 }).pipe(Effect.provide(harness.layer));1299 }).pipe(Effect.provide(harness.layer));
1294});1300});
12951301
1302it.effect("retries a failed workspace preparation on the same run", () => {
1303 let fetchFailures = 1;
1304 const harness = makeHarness({
1305 fetchRemote: () =>
1306 fetchFailures-- > 0
1307 ? Effect.fail(
1308 new GitCommandError({
1309 operation: "GitVcsDriver.fetchRemote",
1310 command: "git",
1311 cwd: project.workspaceRoot,
1312 detail: "Git could not update a local reference.",
1313 exitCode: 1,
1314 }),
1315 )
1316 : Effect.void,
1317 });
1318 return Effect.gen(function* () {
1319 const launches = yield* ThreadLaunch.ThreadLaunchService;
1320 const outbox = yield* EffectOutbox.EffectOutboxV2;
1321 const threads = yield* ThreadManagement.ThreadManagementService;
1322 const launched = yield* launches.launch(
1323 launchInput({
1324 command: "command:launch:retry",
1325 thread: "thread:launch:retry",
1326 message: "Retry me",
1327 workspace: { type: "worktree", baseRef: "main", startFromOrigin: true },
1328 }),
1329 );
1330 yield* waitUntil(() =>
1331 threads
1332 .getThreadProjection(launched.threadId)
1333 .pipe(Effect.map((projection) => projection.runs[0]?.status === "failed")),
1334 );
1335 const failed = yield* threads.getThreadProjection(launched.threadId);
1336 const runId = failed.runs[0]!.id;
1337 assert.equal(
1338 failed.turnItems.find((item) => item.type === "error")?.failure.code,
1339 ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE,
1340 );
1341
1342 const retry = {
1343 commandId: CommandId.make("command:launch:retry:1"),
1344 threadId: launched.threadId,
1345 runId,
1346 };
1347 yield* launches.retryPreparation(retry);
1348 yield* waitUntil(() =>
1349 outbox
1350 .listByCommandId(CommandId.make("command:launch:retry:1:release"))
1351 .pipe(Effect.map((effects) => effects.length === 1)),
1352 );
1353 const retried = yield* threads.getThreadProjection(launched.threadId);
1354 assert.equal(retried.runs.length, 1);
1355 assert.equal(retried.runs[0]?.status, "starting");
1356 assert.equal(retried.thread.worktreePath, "/repo-worktrees/feature");
1357 assert.equal(retried.turnItems.find((item) => item.type === "error")?.status, "cancelled");
1358 assert.equal(
1359 retried.turnItems.find((item) => item.type === "command_execution")?.status,
1360 "completed",
1361 );
1362 assert.equal(harness.createWorktree.mock.calls.length, 1);
1363
1364 // The run left preparation, so a second retry has nothing to do.
1365 const rejected = yield* launches
1366 .retryPreparation({ ...retry, commandId: CommandId.make("command:launch:retry:2") })
1367 .pipe(Effect.flip);
1368 assert.equal(rejected._tag, "OrchestratorDispatchError");
1369 }).pipe(Effect.provide(harness.layer));
1370});
1371
1372it.effect("a retry reuses a recorded worktree without undoing its branch rename", () => {
1373 let setupFailures = 1;
1374 const harness = makeHarness({
1375 runSetup: () =>
1376 setupFailures-- > 0
1377 ? Effect.fail(new Error("setup failed") as never)
1378 : Effect.succeed({ status: "no-script" as const }),
1379 });
1380 return Effect.gen(function* () {
1381 const launches = yield* ThreadLaunch.ThreadLaunchService;
1382 const outbox = yield* EffectOutbox.EffectOutboxV2;
1383 const threads = yield* ThreadManagement.ThreadManagementService;
1384 const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker;
1385 const launched = yield* launches.launch(
1386 launchInput({
1387 command: "command:launch:reuse",
1388 thread: "thread:launch:reuse",
1389 message: "Reuse the worktree",
1390 workspace: { type: "worktree", baseRef: "main" },
1391 }),
1392 );
1393 yield* waitUntil(() =>
1394 threads
1395 .getThreadProjection(launched.threadId)
1396 .pipe(
1397 Effect.map(
1398 (projection) =>
1399 projection.runs[0]?.status === "failed" &&
1400 projection.thread.branch === "generated-branch",
1401 ),
1402 ),
1403 );
1404 const failed = yield* threads.getThreadProjection(launched.threadId);
1405 assert.equal(failed.thread.worktreePath, "/repo-worktrees/feature");
1406
1407 yield* launches.retryPreparation({
1408 commandId: CommandId.make("command:launch:reuse:retry"),
1409 threadId: launched.threadId,
1410 runId: failed.runs[0]!.id,
1411 });
1412 yield* waitUntil(() =>
1413 outbox
1414 .listByCommandId(CommandId.make("command:launch:reuse:retry:release"))
1415 .pipe(Effect.map((effects) => effects.length === 1)),
1416 );
1417 const retried = yield* threads.getThreadProjection(launched.threadId);
1418 assert.equal(retried.runs[0]?.status, "starting");
1419 // The retry neither checks out again nor puts back the temporary branch.
1420 assert.equal(harness.createWorktree.mock.calls.length, 1);
1421 assert.equal(harness.renameBranch.mock.calls.length, 1);
1422 assert.equal(retried.thread.branch, "generated-branch");
1423 assert.equal(retried.thread.worktreePath, "/repo-worktrees/feature");
1424 // Clients see the retry's setup, not the failed one it replaced.
1425 const snapshot = yield* tracker.get(launched.threadId);
1426 assert.equal(snapshot?.phase, "done");
1427 assert.deepEqual(
1428 snapshot?.stages.map((stage) => stage.id),
1429 ["setup-script", "agent"],
1430 );
1431 }).pipe(Effect.provide(harness.layer));
1432});
1433
1434it.effect("removes a worktree that failed before the thread recorded it", () => {
1435 const harness = makeHarness({
1436 // A checkout that dies after claiming its directory.
1437 createWorktree: (_input, options) =>
1438 (options?.progress?.onWorktreeClaimed?.("/repo-worktrees/partial") ?? Effect.void).pipe(
1439 Effect.andThen(Effect.fail(new Error("checkout failed") as never)),
1440 ),
1441 });
1442 return Effect.gen(function* () {
1443 const launches = yield* ThreadLaunch.ThreadLaunchService;
1444 const threads = yield* ThreadManagement.ThreadManagementService;
1445 const launched = yield* launches.launch(
1446 launchInput({
1447 command: "command:launch:partial-worktree",
1448 thread: "thread:launch:partial-worktree",
1449 message: "Partial checkout",
1450 workspace: { type: "worktree", baseRef: "main" },
1451 }),
1452 );
1453 yield* waitUntil(() =>
1454 threads
1455 .getThreadProjection(launched.threadId)
1456 .pipe(Effect.map((projection) => projection.runs[0]?.status === "failed")),
1457 );
1458 const projection = yield* threads.getThreadProjection(launched.threadId);
1459 // Unrecorded, so a retry would create a second checkout beside it.
1460 assert.equal(projection.thread.worktreePath, null);
1461 assert.deepEqual(
1462 harness.removeWorktree.mock.calls.map(([input]) => input.path),
1463 ["/repo-worktrees/partial"],
1464 );
1465 }).pipe(Effect.provide(harness.layer));
1466});
1467
1296it.effect.each(["worktree", "setup"] as const)(1468it.effect.each(["worktree", "setup"] as const)(
1297 "%s failure keeps the thread and message visible and emits failure items",1469 "%s failure keeps the thread and message visible and emits failure items",
1298 (failurePoint) =>1470 (failurePoint) =>
...@@ -1799,6 +1971,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch...@@ -1799,6 +1971,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch
1799 }),1971 }),
1800 ),1972 ),
1801 ),1973 ),
1974 retryPreparation: launches.retryPreparation,
1802 }),1975 }),
1803 Effect.flip,1976 Effect.flip,
1804 );1977 );
apps/server/src/orchestration-v2/ThreadLaunchService.ts+158-34
...@@ -39,6 +39,7 @@ import * as ServerSettings from "../serverSettings.ts";...@@ -39,6 +39,7 @@ import * as ServerSettings from "../serverSettings.ts";
39import * as TextGeneration from "../textGeneration/TextGeneration.ts";39import * as TextGeneration from "../textGeneration/TextGeneration.ts";
40import * as CommandReceiptStore from "./CommandReceiptStore.ts";40import * as CommandReceiptStore from "./CommandReceiptStore.ts";
41import * as IdAllocator from "./IdAllocator.ts";41import * as IdAllocator from "./IdAllocator.ts";
42import type * as Orchestrator from "./Orchestrator.ts";
42import { makeProviderFailure } from "./ProviderFailure.ts";43import { makeProviderFailure } from "./ProviderFailure.ts";
43import { randomUuidV4 } from "./RandomUuid.ts";44import { randomUuidV4 } from "./RandomUuid.ts";
44import * as ThreadManagement from "./ThreadManagementService.ts";45import * as ThreadManagement from "./ThreadManagementService.ts";
...@@ -90,6 +91,25 @@ export interface ThreadLaunchInput {...@@ -90,6 +91,25 @@ export interface ThreadLaunchInput {
90 readonly creationSource: OrchestrationV2CreationSource;91 readonly creationSource: OrchestrationV2CreationSource;
91}92}
9293
94/** What workspace preparation reads from a launch; a retry rebuilds it from the run. */
95type PreparationInput = Pick<
96 ThreadLaunchInput,
97 "commandId" | "projectId" | "workspaceStrategy" | "initialMessage"
98> & {
99 /**
100 * Set when a retry reuses the worktree its failed attempt created and
101 * recorded. Its setup is tracked like a new one, but the thread already
102 * records the workspace, and a branch rename may still be running.
103 */
104 readonly reusedWorktree?: { readonly baseRef: string };
105};
106
107export interface ThreadLaunchRetryInput {
108 readonly commandId: CommandId;
109 readonly threadId: ThreadId;
110 readonly runId: RunId;
111}
112
93export interface ThreadLaunchResult {113export interface ThreadLaunchResult {
94 readonly threadId: ThreadId;114 readonly threadId: ThreadId;
95 readonly projection: OrchestrationV2ThreadProjection;115 readonly projection: OrchestrationV2ThreadProjection;
...@@ -128,6 +148,10 @@ export class ThreadLaunchService extends Context.Service<...@@ -128,6 +148,10 @@ export class ThreadLaunchService extends Context.Service<
128 readonly launch: (148 readonly launch: (
129 input: ThreadLaunchInput,149 input: ThreadLaunchInput,
130 ) => Effect.Effect<ThreadLaunchResult, ThreadLaunchError>;150 ) => Effect.Effect<ThreadLaunchResult, ThreadLaunchError>;
151 /** Dispatches prepared-run.retry and prepares the run's workspace again. */
152 readonly retryPreparation: (
153 input: ThreadLaunchRetryInput,
154 ) => Effect.Effect<Orchestrator.OrchestratorV2DispatchResult, Orchestrator.OrchestratorV2Error>;
131 }155 }
132>()("t3/orchestration-v2/ThreadLaunchService") {}156>()("t3/orchestration-v2/ThreadLaunchService") {}
133157
...@@ -161,7 +185,7 @@ const make = Effect.gen(function* () {...@@ -161,7 +185,7 @@ const make = Effect.gen(function* () {
161 yield* Effect.addFinalizer(() => Scope.close(preparationScope, Exit.void));185 yield* Effect.addFinalizer(() => Scope.close(preparationScope, Exit.void));
162186
163 const mapError =187 const mapError =
164 (input: ThreadLaunchInput, operation: ThreadLaunchError["operation"], threadId?: ThreadId) =>188 (input: PreparationInput, operation: ThreadLaunchError["operation"], threadId?: ThreadId) =>
165 (cause: unknown) =>189 (cause: unknown) =>
166 new ThreadLaunchError({190 new ThreadLaunchError({
167 operation,191 operation,
...@@ -201,7 +225,7 @@ const make = Effect.gen(function* () {...@@ -201,7 +225,7 @@ const make = Effect.gen(function* () {
201 });225 });
202226
203 const prepareInBackground = Effect.fn("ThreadLaunchService.prepareInBackground")(function* (227 const prepareInBackground = Effect.fn("ThreadLaunchService.prepareInBackground")(function* (
204 input: ThreadLaunchInput,228 input: PreparationInput,
205 threadId: ThreadId,229 threadId: ThreadId,
206 runId: RunId | null,230 runId: RunId | null,
207 ) {231 ) {
...@@ -216,10 +240,12 @@ const make = Effect.gen(function* () {...@@ -216,10 +240,12 @@ const make = Effect.gen(function* () {
216 ),240 ),
217 );241 );
218242
219 const tracked = input.workspaceStrategy.type === "worktree";243 const reused = input.reusedWorktree;
244 const tracked = input.workspaceStrategy.type === "worktree" || reused !== undefined;
220 let createdWorktreePath: string | null = null;245 let createdWorktreePath: string | null = null;
221 let setupTerminalId: string | null = null;246 let setupTerminalId: string | null = null;
222 if (tracked) {247 let workspaceRecorded = false;
248 if (input.workspaceStrategy.type === "worktree") {
223 yield* setupTracker.begin({249 yield* setupTracker.begin({
224 threadId,250 threadId,
225 branch: input.workspaceStrategy.branch ?? null,251 branch: input.workspaceStrategy.branch ?? null,
...@@ -227,6 +253,14 @@ const make = Effect.gen(function* () {...@@ -227,6 +253,14 @@ const make = Effect.gen(function* () {
227 stages: ["fetch", "checkout", "setup-script", "agent"],253 stages: ["fetch", "checkout", "setup-script", "agent"],
228 fiber: yield* Effect.fiber,254 fiber: yield* Effect.fiber,
229 });255 });
256 } else if (reused !== undefined) {
257 yield* setupTracker.begin({
258 threadId,
259 branch: input.workspaceStrategy.branch ?? null,
260 baseRef: reused.baseRef,
261 stages: ["setup-script", "agent"],
262 fiber: yield* Effect.fiber,
263 });
230 }264 }
231 yield* Effect.gen(function* () {265 yield* Effect.gen(function* () {
232 const initialMessage = input.initialMessage;266 const initialMessage = input.initialMessage;
...@@ -358,21 +392,27 @@ const make = Effect.gen(function* () {...@@ -358,21 +392,27 @@ const make = Effect.gen(function* () {
358 yield* setupTracker.stageStatus(threadId, "checkout", "done");392 yield* setupTracker.stageStatus(threadId, "checkout", "done");
359 }393 }
360394
361 yield* threads395 // A reused worktree is already recorded, and rewriting it could undo
362 .dispatch({396 // the first attempt's branch rename.
363 type: "thread.metadata.update",397 if (reused === undefined) {
364 commandId: CommandId.make(`${input.commandId}:workspace`),398 yield* threads
365 threadId,399 .dispatch({
366 branch,400 type: "thread.metadata.update",
367 worktreePath,401 commandId: CommandId.make(`${input.commandId}:workspace`),
368 })402 threadId,
369 .pipe(Effect.mapError(mapError(input, "update-thread", threadId)));403 branch,
404 worktreePath,
405 })
406 .pipe(Effect.mapError(mapError(input, "update-thread", threadId)));
407 }
408 workspaceRecorded = true;
370409
371 // Rename temporary branches (server-invented above, or sent by clients410 // Rename temporary branches (server-invented above, or sent by clients
372 // that name worktrees themselves) in the background so generation latency411 // that name worktrees themselves) in the background so generation latency
373 // never delays provisioning or the provider turn. The temporary name412 // never delays provisioning or the provider turn. The temporary name
374 // simply sticks if generation or the rename fails.413 // simply sticks if generation or the rename fails.
375 if (414 if (
415 reused === undefined &&
376 worktreePath !== null &&416 worktreePath !== null &&
377 branch !== null &&417 branch !== null &&
378 initialMessage !== undefined &&418 initialMessage !== undefined &&
...@@ -512,27 +552,41 @@ const make = Effect.gen(function* () {...@@ -512,27 +552,41 @@ const make = Effect.gen(function* () {
512 cancelled ? "cancelled" : "failed",552 cancelled ? "cancelled" : "failed",
513 cancelled ? null : failureDetail(Cause.squash(cause)),553 cancelled ? null : failureDetail(Cause.squash(cause)),
514 );554 );
515 if (cancelled && tracked && createdWorktreePath) {555 // A cancelled setup leaves nothing behind. A failed one keeps a worktree
556 // the thread recorded, so a retry reuses it, and removes one it never
557 // recorded, which a retry would otherwise duplicate.
558 if (tracked && createdWorktreePath && (cancelled || !workspaceRecorded)) {
516 if (setupTerminalId)559 if (setupTerminalId)
517 yield* terminals560 yield* terminals
518 .close({ threadId, terminalId: setupTerminalId, deleteHistory: true })561 .close({ threadId, terminalId: setupTerminalId, deleteHistory: true })
519 .pipe(Effect.ignore);562 .pipe(Effect.ignore);
563 const removedPath = createdWorktreePath;
564 // The thread forgets the worktree only once it is gone; a failed
565 // removal leaves the directory for the user to clean up rather than
566 // reusing a checkout that may be half written.
520 yield* git567 yield* git
521 .removeWorktree({568 .removeWorktree({ cwd: project.workspaceRoot, path: removedPath, force: true })
522 cwd: project.workspaceRoot,569 .pipe(
523 path: createdWorktreePath,570 Effect.andThen(
524 force: true,571 threads
525 })572 .dispatch({
526 .pipe(Effect.ignore);573 type: "thread.metadata.update",
527 yield* threads574 commandId: CommandId.make(`${input.commandId}:cancel-workspace`),
528 .dispatch({575 threadId,
529 type: "thread.metadata.update",576 worktreePath: null,
530 commandId: CommandId.make(`${input.commandId}:cancel-workspace`),577 branch: null,
531 threadId,578 })
532 worktreePath: null,579 .pipe(Effect.ignore),
533 branch: null,580 ),
534 })581 Effect.catchCause((removeCause) =>
535 .pipe(Effect.ignore);582 Effect.logWarning("Failed to remove an abandoned thread worktree", {
583 commandId: input.commandId,
584 threadId,
585 path: removedPath,
586 cause: removeCause,
587 }),
588 ),
589 );
536 }590 }
537 }),591 }),
538 ),592 ),
...@@ -540,7 +594,7 @@ const make = Effect.gen(function* () {...@@ -540,7 +594,7 @@ const make = Effect.gen(function* () {
540 });594 });
541595
542 const failPreparedRun = (596 const failPreparedRun = (
543 input: ThreadLaunchInput,597 input: Pick<PreparationInput, "commandId">,
544 threadId: ThreadId,598 threadId: ThreadId,
545 runId: RunId | null,599 runId: RunId | null,
546 cause: unknown,600 cause: unknown,
...@@ -565,7 +619,6 @@ const make = Effect.gen(function* () {...@@ -565,7 +619,6 @@ const make = Effect.gen(function* () {
565 }),619 }),
566 })620 })
567 .pipe(621 .pipe(
568 Effect.mapError(mapError(input, "fail-run", threadId)),
569 Effect.catchCause((persistCause) =>622 Effect.catchCause((persistCause) =>
570 Effect.logWarning("Failed to persist thread workspace preparation failure", {623 Effect.logWarning("Failed to persist thread workspace preparation failure", {
571 commandId: input.commandId,624 commandId: input.commandId,
...@@ -592,7 +645,7 @@ const make = Effect.gen(function* () {...@@ -592,7 +645,7 @@ const make = Effect.gen(function* () {
592 });645 });
593646
594 const schedulePreparation = Effect.fn("ThreadLaunchService.schedulePreparation")(function* (647 const schedulePreparation = Effect.fn("ThreadLaunchService.schedulePreparation")(function* (
595 input: ThreadLaunchInput,648 input: PreparationInput,
596 threadId: ThreadId,649 threadId: ThreadId,
597 runId: RunId | null,650 runId: RunId | null,
598 ) {651 ) {
...@@ -763,7 +816,7 @@ const make = Effect.gen(function* () {...@@ -763,7 +816,7 @@ const make = Effect.gen(function* () {
763 ...(input.initialMessage.context ? { context: input.initialMessage.context } : {}),816 ...(input.initialMessage.context ? { context: input.initialMessage.context } : {}),
764 ...(input.generateTitle === true ? { titleSeed: input.title } : {}),817 ...(input.generateTitle === true ? { titleSeed: input.title } : {}),
765 modelSelection: input.modelSelection,818 modelSelection: input.modelSelection,
766 dispatchMode: { type: "defer_start" },819 dispatchMode: { type: "defer_start", workspaceStrategy },
767 createdBy: input.createdBy,820 createdBy: input.createdBy,
768 creationSource: input.creationSource,821 creationSource: input.creationSource,
769 })822 })
...@@ -836,7 +889,78 @@ const make = Effect.gen(function* () {...@@ -836,7 +889,78 @@ const make = Effect.gen(function* () {
836 },889 },
837 );890 );
838891
839 return ThreadLaunchService.of({ launch });892 const retryPreparation: ThreadLaunchService["Service"]["retryPreparation"] = Effect.fn(
893 "ThreadLaunchService.retryPreparation",
894 )(function* (input) {
895 const dispatched = yield* threads.dispatch({
896 type: "prepared-run.retry",
897 commandId: input.commandId,
898 threadId: input.threadId,
899 runId: input.runId,
900 });
901 // A replayed retry finds the run already past preparation, or prepared by
902 // the attempt that first reserved this command.
903 // From here the run is preparing again; anything that stops preparation
904 // from being scheduled must fail it, or it would wait in preparing forever.
905 const scheduled = yield* Effect.gen(function* () {
906 const projection = yield* threads.getThreadProjection(input.threadId);
907 const run = projection.runs.find((candidate) => candidate.id === input.runId);
908 const workspacePreparation = run?.workspacePreparation;
909 if (run?.status !== "preparing" || workspacePreparation === undefined) return;
910 if (!(yield* reservePreparation(input.commandId))) return;
911 yield* scheduleRetriedPreparation(input, projection, run, workspacePreparation).pipe(
912 Effect.onError(() => releasePreparation(input.commandId)),
913 );
914 }).pipe(Effect.exit);
915 if (Exit.isFailure(scheduled)) {
916 yield* failPreparedRun(input, input.threadId, input.runId, Cause.squash(scheduled.cause));
917 }
918 return dispatched;
919 });
920
921 const scheduleRetriedPreparation = (
922 input: ThreadLaunchRetryInput,
923 projection: OrchestrationV2ThreadProjection,
924 run: OrchestrationV2ThreadProjection["runs"][number],
925 workspacePreparation: ThreadLaunchWorkspaceStrategy,
926 ) => {
927 const message = projection.messages.find((candidate) => candidate.id === run.userMessageId);
928 // A worktree the failed attempt already created is reused, not created again.
929 const reuse =
930 workspacePreparation.type === "worktree" &&
931 projection.thread.worktreePath !== null &&
932 projection.thread.branch !== null
933 ? {
934 strategy: {
935 type: "existing_worktree" as const,
936 worktreePath: projection.thread.worktreePath,
937 branch: projection.thread.branch,
938 },
939 reusedWorktree: { baseRef: workspacePreparation.baseRef },
940 }
941 : null;
942 return schedulePreparation(
943 {
944 commandId: input.commandId,
945 projectId: projection.thread.projectId,
946 workspaceStrategy: reuse?.strategy ?? workspacePreparation,
947 ...(reuse === null ? {} : { reusedWorktree: reuse.reusedWorktree }),
948 ...(message === undefined
949 ? {}
950 : {
951 initialMessage: {
952 text: message.text,
953 attachments: message.attachments,
954 ...(message.context ? { context: message.context } : {}),
955 },
956 }),
957 },
958 input.threadId,
959 run.id,
960 );
961 };
962
963 return ThreadLaunchService.of({ launch, retryPreparation });
840});964});
841965
842export const layer = Layer.effect(ThreadLaunchService, make);966export const layer = Layer.effect(ThreadLaunchService, make);
apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts+1
...@@ -165,6 +165,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray<Thread...@@ -165,6 +165,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray<Thread
165 case "prepared-run.release":165 case "prepared-run.release":
166 case "prepared-run.progress":166 case "prepared-run.progress":
167 case "prepared-run.fail":167 case "prepared-run.fail":
168 case "prepared-run.retry":
168 case "run.interrupt":169 case "run.interrupt":
169 case "queued-message.promote-to-steer":170 case "queued-message.promote-to-steer":
170 case "queue.resume":171 case "queue.resume":
apps/server/src/ws.ts+10-5
...@@ -1796,11 +1796,16 @@ const makeWsRpcLayer = (...@@ -1796,11 +1796,16 @@ const makeWsRpcLayer = (
1796 ORCHESTRATION_V2_WS_METHODS.dispatchCommand,1796 ORCHESTRATION_V2_WS_METHODS.dispatchCommand,
1797 startup1797 startup
1798 .enqueueCommand(1798 .enqueueCommand(
1799 ThreadMessageIntake.dispatchCommand(1799 // A retry also restarts the preparation work the launch owns.
1800 ThreadManagementService.withCreationProvenance(command, {1800 (command.type === "prepared-run.retry"
1801 createdBy: "user",1801 ? threadLaunch.retryPreparation(command)
1802 creationSource: "creationSource" in command ? command.creationSource : "web",1802 : ThreadMessageIntake.dispatchCommand(
1803 }),1803 ThreadManagementService.withCreationProvenance(command, {
1804 createdBy: "user",
1805 creationSource:
1806 "creationSource" in command ? command.creationSource : "web",
1807 }),
1808 )
1804 ).pipe(Effect.provide(intakeContext)),1809 ).pipe(Effect.provide(intakeContext)),
1805 )1810 )
1806 .pipe(1811 .pipe(
apps/web/src/components/ChatView.tsx+35
...@@ -395,6 +395,7 @@ import {...@@ -395,6 +395,7 @@ import {
395} from "../state/server";395} from "../state/server";
396import { terminalEnvironment } from "../state/terminal";396import { terminalEnvironment } from "../state/terminal";
397import { threadEnvironment } from "../state/threads";397import { threadEnvironment } from "../state/threads";
398import { workspacePreparationRetryRunIds } from "@t3tools/client-runtime/state/turn-item-presentation";
398import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";399import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
399import { vcsEnvironment } from "../state/vcs";400import { vcsEnvironment } from "../state/vcs";
400import { sourceControlEnvironment } from "../state/sourceControl";401import { sourceControlEnvironment } from "../state/sourceControl";
...@@ -1510,6 +1511,9 @@ function releaseChatTimelineAnchor<T extends { readonly messageId: MessageId | n...@@ -1510,6 +1511,9 @@ function releaseChatTimelineAnchor<T extends { readonly messageId: MessageId | n
1510 return current.messageId === null ? current : { ...current, messageId: null };1511 return current.messageId === null ? current : { ...current, messageId: null };
1511}1512}
15121513
1514/** Runs with a workspace preparation retry in flight, across ChatView instances. */
1515const retryingWorkspacePreparationRunIds = new Set<RunId>();
1516
1513export default function ChatView(props: ChatViewProps) {1517export default function ChatView(props: ChatViewProps) {
1514 const {1518 const {
1515 environmentId,1519 environmentId,
...@@ -3920,6 +3924,34 @@ export default function ChatView(props: ChatViewProps) {...@@ -3920,6 +3924,34 @@ export default function ChatView(props: ChatViewProps) {
3920 const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, {3924 const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, {
3921 reportFailure: false,3925 reportFailure: false,
3922 });3926 });
3927 const retryWorkspacePreparation = useAtomCommand(threadEnvironment.retryWorkspacePreparation);
3928 const retryableRunIdsKey = useMemo(
3929 () =>
3930 [
3931 ...workspacePreparationRetryRunIds(
3932 serverProjection?.runs ?? [],
3933 serverProjection?.turnItems ?? [],
3934 ),
3935 ].join("\n"),
3936 [serverProjection?.runs, serverProjection?.turnItems],
3937 );
3938 // Keyed by content so the timeline context only changes when a retry appears or clears.
3939 const retryableWorkspacePreparationRunIds = useMemo(
3940 () => new Set(retryableRunIdsKey === "" ? [] : (retryableRunIdsKey.split("\n") as RunId[])),
3941 [retryableRunIdsKey],
3942 );
3943 const onRetryWorkspacePreparation = useCallback(
3944 (runId: RunId) => {
3945 // One retry per failed run: a second click lands after the run is preparing again.
3946 if (!activeThreadRef || retryingWorkspacePreparationRunIds.has(runId)) return;
3947 retryingWorkspacePreparationRunIds.add(runId);
3948 void retryWorkspacePreparation({
3949 environmentId: activeThreadRef.environmentId,
3950 input: { threadId: activeThreadRef.threadId, runId },
3951 }).finally(() => retryingWorkspacePreparationRunIds.delete(runId));
3952 },
3953 [activeThreadRef, retryWorkspacePreparation],
3954 );
3923 const onCancelWorktreeSetup = useCallback(() => {3955 const onCancelWorktreeSetup = useCallback(() => {
3924 if (!worktreeSetup || worktreeSetup.phase !== "running") return;3956 if (!worktreeSetup || worktreeSetup.phase !== "running") return;
3925 void cancelWorktreeSetup({3957 void cancelWorktreeSetup({
...@@ -10896,6 +10928,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -10896,6 +10928,9 @@ export default function ChatView(props: ChatViewProps) {
10896 activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt}10928 activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt}
10897 worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup}10929 worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup}
10898 onCancelWorktreeSetup={onCancelWorktreeSetup}10930 onCancelWorktreeSetup={onCancelWorktreeSetup}
10931 {...(paintOnlyDisplayedTimeline
10932 ? {}
10933 : { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation })}
10899 {...(draftId ? { onWorktreeSetupWorkLocally } : {})}10934 {...(draftId ? { onWorktreeSetupWorkLocally } : {})}
10900 {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})}10935 {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})}
10901 isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree}10936 isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree}
apps/web/src/components/chat/MessagesTimeline.tsx+34
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18} from "./timelineMinimapItems";18} from "./timelineMinimapItems";
19import {19import {
20 COMPOSER_CONTEXT_KINDS,20 COMPOSER_CONTEXT_KINDS,
21 ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE,
21 type AssistantCitation,22 type AssistantCitation,
22 type EnvironmentId,23 type EnvironmentId,
23 type MessageId,24 type MessageId,
...@@ -139,6 +140,7 @@ import {...@@ -139,6 +140,7 @@ import {
139 WrenchIcon,140 WrenchIcon,
140 XIcon,141 XIcon,
141 ZapIcon,142 ZapIcon,
143 RotateCcwIcon,
142} from "lucide-react";144} from "lucide-react";
143import { ChevronDown, ChevronRight } from "lucide";145import { ChevronDown, ChevronRight } from "lucide";
144import type {146import type {
...@@ -325,6 +327,8 @@ interface TimelineRowSharedState {...@@ -325,6 +327,8 @@ interface TimelineRowSharedState {
325 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;327 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
326 onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void;328 onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void;
327 onCancelWorktreeSetup: (() => void) | null;329 onCancelWorktreeSetup: (() => void) | null;
330 retryableWorkspacePreparationRunIds: ReadonlySet<RunId>;
331 onRetryWorkspacePreparation: ((runId: RunId) => void) | null;
328 onWorktreeSetupWorkLocally: (() => void) | null;332 onWorktreeSetupWorkLocally: (() => void) | null;
329 onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null;333 onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null;
330 workGroupViewState: WorkGroupViewState;334 workGroupViewState: WorkGroupViewState;
...@@ -383,6 +387,7 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = {...@@ -383,6 +387,7 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = {
383 },387 },
384} as const satisfies MaintainScrollAtEndOptions;388} as const satisfies MaintainScrollAtEndOptions;
385const EMPTY_TIMELINE_RUNS: ReadonlyArray<HandoffTimelineRun> = [];389const EMPTY_TIMELINE_RUNS: ReadonlyArray<HandoffTimelineRun> = [];
390const EMPTY_RUN_IDS: ReadonlySet<RunId> = new Set();
386// Streamed text lands a paragraph at a time. A smooth scroll to the end391// Streamed text lands a paragraph at a time. A smooth scroll to the end
387// turns each landing into a short glide instead of a jump. Thread switches392// turns each landing into a short glide instead of a jump. Thread switches
388// and layout settles keep the instant variant so nothing visibly travels.393// and layout settles keep the instant variant so nothing visibly travels.
...@@ -416,6 +421,9 @@ interface MessagesTimelineProps {...@@ -416,6 +421,9 @@ interface MessagesTimelineProps {
416 activeTurnStartedAt?: string | null;421 activeTurnStartedAt?: string | null;
417 worktreeSetup?: WorktreeSetupSnapshot | null;422 worktreeSetup?: WorktreeSetupSnapshot | null;
418 onCancelWorktreeSetup?: () => void;423 onCancelWorktreeSetup?: () => void;
424 /** Runs whose failed workspace preparation can be retried, keyed by run id. */
425 retryableWorkspacePreparationRunIds?: ReadonlySet<RunId>;
426 onRetryWorkspacePreparation?: (runId: RunId) => void;
419 onWorktreeSetupWorkLocally?: () => void;427 onWorktreeSetupWorkLocally?: () => void;
420 onOpenWorktreeSetupTerminal?: (terminalId: string) => void;428 onOpenWorktreeSetupTerminal?: (terminalId: string) => void;
421 isPreparingWorktree?: boolean;429 isPreparingWorktree?: boolean;
...@@ -499,6 +507,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -499,6 +507,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
499 activeTurnStartedAt = null,507 activeTurnStartedAt = null,
500 worktreeSetup = null,508 worktreeSetup = null,
501 onCancelWorktreeSetup,509 onCancelWorktreeSetup,
510 retryableWorkspacePreparationRunIds = EMPTY_RUN_IDS,
511 onRetryWorkspacePreparation,
502 onWorktreeSetupWorkLocally,512 onWorktreeSetupWorkLocally,
503 onOpenWorktreeSetupTerminal,513 onOpenWorktreeSetupTerminal,
504 isPreparingWorktree = false,514 isPreparingWorktree = false,
...@@ -1164,6 +1174,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -1164,6 +1174,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
1164 onToggleWorkGroup,1174 onToggleWorkGroup,
1165 onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure,1175 onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure,
1166 onCancelWorktreeSetup: onCancelWorktreeSetup ?? null,1176 onCancelWorktreeSetup: onCancelWorktreeSetup ?? null,
1177 retryableWorkspacePreparationRunIds,
1178 onRetryWorkspacePreparation: onRetryWorkspacePreparation ?? null,
1167 onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null,1179 onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null,
1168 onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null,1180 onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null,
1169 workGroupViewState,1181 workGroupViewState,
...@@ -1197,6 +1209,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -1197,6 +1209,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
1197 onToggleWorkGroup,1209 onToggleWorkGroup,
1198 suspendEndScrollMaintenanceForDisclosure,1210 suspendEndScrollMaintenanceForDisclosure,
1199 onCancelWorktreeSetup,1211 onCancelWorktreeSetup,
1212 retryableWorkspacePreparationRunIds,
1213 onRetryWorkspacePreparation,
1200 onWorktreeSetupWorkLocally,1214 onWorktreeSetupWorkLocally,
1201 onOpenWorktreeSetupTerminal,1215 onOpenWorktreeSetupTerminal,
1202 workGroupViewState,1216 workGroupViewState,
...@@ -4980,6 +4994,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {...@@ -4980,6 +4994,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
4980 const { workEntry, workspaceRoot, displayLabel } = props;4994 const { workEntry, workspaceRoot, displayLabel } = props;
4981 const ctx = use(TimelineRowCtx);4995 const ctx = use(TimelineRowCtx);
4982 const { threadRef, onImageExpand, timestampFormat } = ctx;4996 const { threadRef, onImageExpand, timestampFormat } = ctx;
4997 const { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation } = ctx;
4983 const createdThread =4998 const createdThread =
4984 workEntry.projectedItem?.item.type === "thread_created"4999 workEntry.projectedItem?.item.type === "thread_created"
4985 ? workEntry.projectedItem.item5000 ? workEntry.projectedItem.item
...@@ -5011,6 +5026,12 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {...@@ -5011,6 +5026,12 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
5011 const label = warning5026 const label = warning
5012 ? `Usage limit reached.${resetTime ? ` Retry after ${resetTime}.` : ""}`5027 ? `Usage limit reached.${resetTime ? ` Retry after ${resetTime}.` : ""}`
5013 : workEntry.label;5028 : workEntry.label;
5029 const retryRunId =
5030 failureItem.runId !== null &&
5031 retryableWorkspacePreparationRunIds.has(failureItem.runId) &&
5032 failureItem.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE
5033 ? failureItem.runId
5034 : null;
5014 return (5035 return (
5015 <WorkLogRow5036 <WorkLogRow
5016 data-v2-item-type="error"5037 data-v2-item-type="error"
...@@ -5041,6 +5062,19 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {...@@ -5041,6 +5062,19 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
5041 {failureItem.failure.message}5062 {failureItem.failure.message}
5042 </p>5063 </p>
5043 ) : null}5064 ) : null}
5065 {retryRunId !== null && onRetryWorkspacePreparation ? (
5066 <div className="ms-7 pb-1">
5067 <Button
5068 type="button"
5069 size="xs"
5070 variant="outline"
5071 onClick={() => onRetryWorkspacePreparation(retryRunId)}
5072 >
5073 <RotateCcwIcon aria-hidden />
5074 Retry
5075 </Button>
5076 </div>
5077 ) : null}
5044 </WorkLogRow>5078 </WorkLogRow>
5045 );5079 );
5046 }5080 }
packages/client-runtime/src/operations/commands.ts+15
...@@ -237,6 +237,10 @@ export interface CancelQueuedRunInput extends ThreadCommandInput {...@@ -237,6 +237,10 @@ export interface CancelQueuedRunInput extends ThreadCommandInput {
237 readonly runId: RunId;237 readonly runId: RunId;
238}238}
239239
240export interface RetryWorkspacePreparationInput extends ThreadCommandInput {
241 readonly runId: RunId;
242}
243
240export interface EditQueuedRunInput extends ThreadCommandInput {244export interface EditQueuedRunInput extends ThreadCommandInput {
241 readonly runId: RunId;245 readonly runId: RunId;
242 readonly text: string;246 readonly text: string;
...@@ -982,6 +986,17 @@ export const cancelQueuedRun = Effect.fn("EnvironmentCommands.cancelQueuedRun")(...@@ -982,6 +986,17 @@ export const cancelQueuedRun = Effect.fn("EnvironmentCommands.cancelQueuedRun")(
982 });986 });
983});987});
984988
989export const retryWorkspacePreparation = Effect.fn("EnvironmentCommands.retryWorkspacePreparation")(
990 function* (input: RetryWorkspacePreparationInput) {
991 return yield* dispatch({
992 type: "prepared-run.retry",
993 commandId: yield* allocateCommandId(input),
994 threadId: input.threadId,
995 runId: input.runId,
996 });
997 },
998);
999
985export const editQueuedRun = Effect.fn("EnvironmentCommands.editQueuedRun")(function* (1000export const editQueuedRun = Effect.fn("EnvironmentCommands.editQueuedRun")(function* (
986 input: EditQueuedRunInput,1001 input: EditQueuedRunInput,
987) {1002) {
packages/client-runtime/src/state/threadCommands.ts+8
...@@ -21,6 +21,7 @@ import {...@@ -21,6 +21,7 @@ import {
21 type ThreadCommandInput,21 type ThreadCommandInput,
22 type ArchiveThreadInput,22 type ArchiveThreadInput,
23 type CancelQueuedRunInput,23 type CancelQueuedRunInput,
24 type RetryWorkspacePreparationInput,
24 type CreateThreadInput,25 type CreateThreadInput,
25 type DeleteThreadInput,26 type DeleteThreadInput,
26 type EditQueuedRunInput,27 type EditQueuedRunInput,
...@@ -65,6 +66,7 @@ import {...@@ -65,6 +66,7 @@ import {
65 promoteQueuedRun,66 promoteQueuedRun,
66 reorderQueuedRun,67 reorderQueuedRun,
67 resumeThreadQueue,68 resumeThreadQueue,
69 retryWorkspacePreparation,
68 linkThreadPullRequest,70 linkThreadPullRequest,
69 respondToThreadApproval,71 respondToThreadApproval,
70 respondToThreadUserInput,72 respondToThreadUserInput,
...@@ -357,6 +359,12 @@ export function createThreadEnvironmentAtoms<R, E>(...@@ -357,6 +359,12 @@ export function createThreadEnvironmentAtoms<R, E>(
357 scheduler,359 scheduler,
358 concurrency,360 concurrency,
359 }),361 }),
362 retryWorkspacePreparation: createEnvironmentCommand(runtime, {
363 label: "environment-data:commands:thread:retry-workspace-preparation",
364 execute: (input: RetryWorkspacePreparationInput) => retryWorkspacePreparation(input),
365 scheduler,
366 concurrency,
367 }),
360 editQueuedRun: createEnvironmentCommand(runtime, {368 editQueuedRun: createEnvironmentCommand(runtime, {
361 label: "environment-data:commands:thread:edit-queued-run",369 label: "environment-data:commands:thread:edit-queued-run",
362 execute: (input: EditQueuedRunInput) => editQueuedRun(input),370 execute: (input: EditQueuedRunInput) => editQueuedRun(input),
packages/client-runtime/src/state/turnItemPresentation.test.ts+70-2
...@@ -1,8 +1,18 @@...@@ -1,8 +1,18 @@
1import { RunId, ThreadId, TurnItemId, type OrchestrationV2TurnItem } from "@t3tools/contracts";1import {
2 ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE,
3 RunId,
4 ThreadId,
5 TurnItemId,
6 type OrchestrationV2Run,
7 type OrchestrationV2TurnItem,
8} from "@t3tools/contracts";
2import * as DateTime from "effect/DateTime";9import * as DateTime from "effect/DateTime";
3import { describe, expect, it } from "vite-plus/test";10import { describe, expect, it } from "vite-plus/test";
411
5import { turnItemIsWorkspacePreparation } from "./turnItemPresentation.ts";12import {
13 turnItemIsWorkspacePreparation,
14 workspacePreparationRetryRunIds,
15} from "./turnItemPresentation.ts";
616
7function command(input: string): OrchestrationV2TurnItem {17function command(input: string): OrchestrationV2TurnItem {
8 const now = DateTime.makeUnsafe("2026-08-03T00:00:00.000Z");18 const now = DateTime.makeUnsafe("2026-08-03T00:00:00.000Z");
...@@ -28,9 +38,67 @@ function command(input: string): OrchestrationV2TurnItem {...@@ -28,9 +38,67 @@ function command(input: string): OrchestrationV2TurnItem {
28 };38 };
29}39}
3040
41function preparationFailure(
42 status: OrchestrationV2TurnItem["status"],
43 code: string | null = ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE,
44): OrchestrationV2TurnItem {
45 const {
46 input: _input,
47 output: _output,
48 exitCode: _exitCode,
49 ...base
50 } = command("") as Extract<OrchestrationV2TurnItem, { type: "command_execution" }>;
51 return {
52 ...base,
53 id: TurnItemId.make("item-error"),
54 status,
55 title: "Workspace preparation failed",
56 type: "error",
57 failure: { class: "validation_error", message: "fetch failed", code, retryable: false },
58 };
59}
60
61const worktree = { type: "worktree", baseRef: "main" } as const;
62function run(
63 status: OrchestrationV2Run["status"],
64 workspacePreparation?: typeof worktree,
65): Pick<OrchestrationV2Run, "id" | "status" | "workspacePreparation"> {
66 return {
67 id: RunId.make("run-1"),
68 status,
69 ...(workspacePreparation === undefined ? {} : { workspacePreparation }),
70 };
71}
72
31describe("turnItemIsWorkspacePreparation", () => {73describe("turnItemIsWorkspacePreparation", () => {
32 it("identifies the synthetic workspace preparation command", () => {74 it("identifies the synthetic workspace preparation command", () => {
33 expect(turnItemIsWorkspacePreparation(command("Preparing workspace"))).toBe(true);75 expect(turnItemIsWorkspacePreparation(command("Preparing workspace"))).toBe(true);
34 expect(turnItemIsWorkspacePreparation(command("prepare workspace"))).toBe(false);76 expect(turnItemIsWorkspacePreparation(command("prepare workspace"))).toBe(false);
35 });77 });
78
79 it("hides a preparation failure only once a retry cancelled it", () => {
80 expect(turnItemIsWorkspacePreparation(preparationFailure("failed"))).toBe(false);
81 expect(turnItemIsWorkspacePreparation(preparationFailure("cancelled"))).toBe(true);
82 expect(turnItemIsWorkspacePreparation(preparationFailure("cancelled", null))).toBe(false);
83 });
84});
85
86describe("workspacePreparationRetryRunIds", () => {
87 it("offers a retry while the run still ends in its failed preparation", () => {
88 const failure = [preparationFailure("failed")];
89 expect([...workspacePreparationRetryRunIds([run("failed", worktree)], failure)]).toEqual([
90 RunId.make("run-1"),
91 ]);
92 // Retried: the run is preparing again and the old failure is cancelled.
93 expect(workspacePreparationRetryRunIds([run("preparing", worktree)], failure).size).toBe(0);
94 // An older server records no preparation, so there is nothing to repeat.
95 expect(workspacePreparationRetryRunIds([run("failed")], failure).size).toBe(0);
96 // A provider error on a run that did reach the provider is not a preparation failure.
97 expect(
98 workspacePreparationRetryRunIds(
99 [run("failed", worktree)],
100 [preparationFailure("failed", "provider_crashed")],
101 ).size,
102 ).toBe(0);
103 });
36});104});
packages/client-runtime/src/state/turnItemPresentation.ts+44-3
...@@ -1,8 +1,49 @@...@@ -1,8 +1,49 @@
1import type { OrchestrationV2TurnItem } from "@t3tools/contracts";1import {
2 ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE,
3 type OrchestrationV2Run,
4 type OrchestrationV2TurnItem,
5} from "@t3tools/contracts";
26
3const WORKSPACE_PREPARATION_INPUT = "Preparing workspace";7const WORKSPACE_PREPARATION_INPUT = "Preparing workspace";
48
5/** Workspace setup is client bookkeeping; preparation failures have their own error item. */9/**
10 * Workspace setup is client bookkeeping; preparation failures have their own
11 * error item. A retry cancels that item, which then has nothing left to say.
12 */
6export function turnItemIsWorkspacePreparation(item: OrchestrationV2TurnItem): boolean {13export function turnItemIsWorkspacePreparation(item: OrchestrationV2TurnItem): boolean {
7 return item.type === "command_execution" && item.input === WORKSPACE_PREPARATION_INPUT;14 return (
15 (item.type === "command_execution" && item.input === WORKSPACE_PREPARATION_INPUT) ||
16 (item.type === "error" &&
17 item.status === "cancelled" &&
18 item.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE)
19 );
20}
21
22/**
23 * Runs a Retry can prepare again: their workspace preparation failed and the
24 * run still ended there. Older servers record no preparation on the run, so
25 * they never offer it.
26 */
27export function workspacePreparationRetryRunIds(
28 runs: ReadonlyArray<Pick<OrchestrationV2Run, "id" | "status" | "workspacePreparation">>,
29 items: ReadonlyArray<OrchestrationV2TurnItem>,
30): ReadonlySet<OrchestrationV2Run["id"]> {
31 const failedRuns = new Set(
32 runs.flatMap((run) =>
33 run.status === "failed" && run.workspacePreparation !== undefined ? [run.id] : [],
34 ),
35 );
36 const retryable = new Set<OrchestrationV2Run["id"]>();
37 if (failedRuns.size === 0) return retryable;
38 for (const item of items) {
39 if (
40 item.type === "error" &&
41 item.status === "failed" &&
42 item.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE &&
43 item.runId !== null &&
44 failedRuns.has(item.runId)
45 )
46 retryable.add(item.runId);
47 }
48 return retryable;
8}49}
packages/contracts/src/orchestrationV2.ts+36-21
...@@ -508,6 +508,29 @@ export const OrchestrationV2RunBackgroundWorkCancelled = Schema.Struct({...@@ -508,6 +508,29 @@ export const OrchestrationV2RunBackgroundWorkCancelled = Schema.Struct({
508export type OrchestrationV2RunBackgroundWorkCancelled =508export type OrchestrationV2RunBackgroundWorkCancelled =
509 typeof OrchestrationV2RunBackgroundWorkCancelled.Type;509 typeof OrchestrationV2RunBackgroundWorkCancelled.Type;
510510
511export const OrchestrationV2ThreadLaunchWorkspaceStrategy = Schema.Union([
512 Schema.Struct({
513 type: Schema.Literal("root"),
514 branch: Schema.optional(TrimmedNonEmptyString),
515 }),
516 Schema.Struct({
517 type: Schema.Literal("existing_worktree"),
518 worktreePath: TrimmedNonEmptyString,
519 branch: Schema.optional(TrimmedNonEmptyString),
520 }),
521 Schema.Struct({
522 type: Schema.Literal("worktree"),
523 baseRef: TrimmedNonEmptyString,
524 branch: Schema.optional(TrimmedNonEmptyString),
525 startFromOrigin: Schema.optional(Schema.Boolean),
526 }),
527]);
528export type OrchestrationV2ThreadLaunchWorkspaceStrategy =
529 typeof OrchestrationV2ThreadLaunchWorkspaceStrategy.Type;
530
531/** Failure code on the error item a failed workspace preparation leaves. */
532export const ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE = "workspace_preparation_failed";
533
511export const OrchestrationV2Run = Schema.Struct({534export const OrchestrationV2Run = Schema.Struct({
512 id: RunId,535 id: RunId,
513 threadId: ThreadId,536 threadId: ThreadId,
...@@ -549,6 +572,8 @@ export const OrchestrationV2Run = Schema.Struct({...@@ -549,6 +572,8 @@ export const OrchestrationV2Run = Schema.Struct({
549 }),572 }),
550 ),573 ),
551 delegatedCompletion: Schema.optional(OrchestrationV2DelegatedCompletionCohort),574 delegatedCompletion: Schema.optional(OrchestrationV2DelegatedCompletionCohort),
575 /** How a launch prepares this run's workspace; prepared-run.retry repeats it. */
576 workspacePreparation: Schema.optional(OrchestrationV2ThreadLaunchWorkspaceStrategy),
552});577});
553export type OrchestrationV2Run = typeof OrchestrationV2Run.Type;578export type OrchestrationV2Run = typeof OrchestrationV2Run.Type;
554579
...@@ -2691,7 +2716,10 @@ export const OrchestrationV2Command = Schema.Union([...@@ -2691,7 +2716,10 @@ export const OrchestrationV2Command = Schema.Union([
2691 }),2716 }),
2692 ),2717 ),
2693 dispatchMode: Schema.Union([2718 dispatchMode: Schema.Union([
2694 Schema.Struct({ type: Schema.Literal("defer_start") }),2719 Schema.Struct({
2720 type: Schema.Literal("defer_start"),
2721 workspaceStrategy: Schema.optional(OrchestrationV2ThreadLaunchWorkspaceStrategy),
2722 }),
2695 Schema.Struct({ type: Schema.Literal("steer_active"), targetRunId: RunId }),2723 Schema.Struct({ type: Schema.Literal("steer_active"), targetRunId: RunId }),
2696 Schema.Struct({ type: Schema.Literal("restart_active"), targetRunId: RunId }),2724 Schema.Struct({ type: Schema.Literal("restart_active"), targetRunId: RunId }),
2697 Schema.Struct({ type: Schema.Literal("queue_after_active") }),2725 Schema.Struct({ type: Schema.Literal("queue_after_active") }),
...@@ -2725,6 +2753,13 @@ export const OrchestrationV2Command = Schema.Union([...@@ -2725,6 +2753,13 @@ export const OrchestrationV2Command = Schema.Union([
2725 runId: RunId,2753 runId: RunId,
2726 failure: OrchestrationV2ProviderFailure,2754 failure: OrchestrationV2ProviderFailure,
2727 }),2755 }),
2756 /** Puts a run whose workspace preparation failed back into preparation. */
2757 Schema.Struct({
2758 type: Schema.Literal("prepared-run.retry"),
2759 commandId: CommandId,
2760 threadId: ThreadId,
2761 runId: RunId,
2762 }),
2728 Schema.Struct({2763 Schema.Struct({
2729 type: Schema.Literal("run.interrupt"),2764 type: Schema.Literal("run.interrupt"),
2730 commandId: CommandId,2765 commandId: CommandId,
...@@ -2961,26 +2996,6 @@ export const OrchestrationV2ArchivedShellStreamItem = Schema.Union([...@@ -2961,26 +2996,6 @@ export const OrchestrationV2ArchivedShellStreamItem = Schema.Union([
2961export type OrchestrationV2ArchivedShellStreamItem =2996export type OrchestrationV2ArchivedShellStreamItem =
2962 typeof OrchestrationV2ArchivedShellStreamItem.Type;2997 typeof OrchestrationV2ArchivedShellStreamItem.Type;
29632998
2964export const OrchestrationV2ThreadLaunchWorkspaceStrategy = Schema.Union([
2965 Schema.Struct({
2966 type: Schema.Literal("root"),
2967 branch: Schema.optional(TrimmedNonEmptyString),
2968 }),
2969 Schema.Struct({
2970 type: Schema.Literal("existing_worktree"),
2971 worktreePath: TrimmedNonEmptyString,
2972 branch: Schema.optional(TrimmedNonEmptyString),
2973 }),
2974 Schema.Struct({
2975 type: Schema.Literal("worktree"),
2976 baseRef: TrimmedNonEmptyString,
2977 branch: Schema.optional(TrimmedNonEmptyString),
2978 startFromOrigin: Schema.optional(Schema.Boolean),
2979 }),
2980]);
2981export type OrchestrationV2ThreadLaunchWorkspaceStrategy =
2982 typeof OrchestrationV2ThreadLaunchWorkspaceStrategy.Type;
2983
2984export const OrchestrationV2ThreadLaunchInput = Schema.Struct({2999export const OrchestrationV2ThreadLaunchInput = Schema.Struct({
2985 commandId: CommandId,3000 commandId: CommandId,
2986 creationSource: Schema.optional(OrchestrationV2CreationSource),3001 creationSource: Schema.optional(OrchestrationV2CreationSource),