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 {
4141 View,
4242} from "react-native";
4343import 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";
4555import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
4656
4757import { AppText as Text } from "../../components/AppText";
......@@ -770,6 +780,44 @@ function workLogRowKey(row: ThreadFeedActivity): string {
770780 return row.id;
771781}
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
773821const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
774822 props: Omit<
775823 ThreadWorkLogProps,
......@@ -850,6 +898,14 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
850898 {failureItem.failure.message}
851899 </Text>
852900 ) : 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}
853909 </View>
854910 </WorkLogPressable>
855911 );
apps/server/src/orchestration-v2/Orchestrator.ts+110-3
......@@ -12,6 +12,7 @@ import {
1212 legacyThreadPullRequestKey,
1313} from "@t3tools/shared/threadPullRequests";
1414import {
15 ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE,
1516 type ChatAttachment,
1617 CommandId,
1718 isProviderNativeSubagentThread,
......@@ -400,6 +401,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {
400401 case "prepared-run.release":
401402 case "prepared-run.progress":
402403 case "prepared-run.fail":
404 case "prepared-run.retry":
403405 case "run.interrupt":
404406 case "queued-message.promote-to-steer":
405407 case "queue.resume":
......@@ -442,6 +444,14 @@ function pendingThreadTitleGenerationEffect(
442444
443445const 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
445455function isBlockingRun(run: OrchestrationV2Run): boolean {
446456 return (
447457 run.status === "preparing" ||
......@@ -5150,6 +5160,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
51505160 ...(command.restartContinuationOfRunId === undefined
51515161 ? {}
51525162 : { restartContinuationOfRunId: command.restartContinuationOfRunId }),
5163 ...(dispatchMode.type === "defer_start" && dispatchMode.workspaceStrategy !== undefined
5164 ? { workspacePreparation: dispatchMode.workspaceStrategy }
5165 : {}),
51535166 ...wakeWorkStartedAt(projection.runs, command),
51545167 };
51555168 const attempt: OrchestrationV2RunAttempt = {
......@@ -7489,7 +7502,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
74897502 command: Extract<
74907503 OrchestrationV2Command,
74917504 {
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";
74937510 }
74947511 >,
74957512 projection: Pick<
......@@ -7512,7 +7529,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
75127529 candidate.input === WORKSPACE_PREPARATION_INPUT,
75137530 );
75147531 if (
7515 run?.status !== "preparing" ||
7532 run?.status !== (command.type === "prepared-run.retry" ? "failed" : "preparing") ||
75167533 attempt === undefined ||
75177534 rootNode === undefined ||
75187535 providerThread === undefined ||
......@@ -7730,7 +7747,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
77307747 completedAt: now,
77317748 updatedAt: now,
77327749 type: "error",
7733 failure: command.failure,
7750 failure: {
7751 ...command.failure,
7752 code: ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE,
7753 },
77347754 },
77357755 });
77367756 yield* emitEvent({
......@@ -7744,6 +7764,90 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
77447764 });
77457765 });
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
77477851 /**
77487852 * Ends the background work a settled thread still shows that no provider
77497853 * process will report on: work on the provider thread whose interrupt just
......@@ -9450,6 +9554,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
94509554 case "prepared-run.fail":
94519555 yield* dispatchPreparedRunFail(command, events);
94529556 break;
9557 case "prepared-run.retry":
9558 yield* dispatchPreparedRunRetry(command, events);
9559 break;
94539560 case "runtime-request.respond":
94549561 yield* dispatchRuntimeRequestRespond(command, events, effects);
94559562 break;
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+174-1
......@@ -10,6 +10,7 @@ import * as ThreadMessageIntake from "./ThreadMessageIntake.ts";
1010import { assert, it, vi } from "@effect/vitest";
1111import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
1212import {
13 ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE,
1314 ChatAttachmentId,
1415 ComposerContextId,
1516 type ChatAttachment,
......@@ -127,6 +128,10 @@ function makeHarness(options: HarnessOptions = {}) {
127128 const renameBranch = vi.fn(
128129 options.renameBranch ?? ((input) => Effect.succeed({ branch: input.newBranch })),
129130 );
131 const removeWorktree = vi.fn(
132 (_input: Parameters<GitWorkflow.GitWorkflowService["Service"]["removeWorktree"]>[0]) =>
133 Effect.void,
134 );
130135 const runSetup = vi.fn(
131136 options.runSetup ?? (() => Effect.succeed({ status: "no-script" as const })),
132137 );
......@@ -164,7 +169,7 @@ function makeHarness(options: HarnessOptions = {}) {
164169 fetchRemote: options.fetchRemote ?? (() => Effect.void),
165170 remoteExists: () => Effect.succeed(true),
166171 remoteBranchExists: () => Effect.succeed(true),
167 removeWorktree: () => Effect.void,
172 removeWorktree,
168173 resolveRemoteTrackingCommit: () =>
169174 Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" }),
170175 }),
......@@ -220,6 +225,7 @@ function makeHarness(options: HarnessOptions = {}) {
220225 externalServices,
221226 ),
222227 createWorktree,
228 removeWorktree,
223229 renameBranch,
224230 generateBranchName,
225231 generateThreadTitle,
......@@ -1293,6 +1299,172 @@ it.effect("shows the fetch diagnosis when preparing a worktree from origin fails
12931299 }).pipe(Effect.provide(harness.layer));
12941300});
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
12961468it.effect.each(["worktree", "setup"] as const)(
12971469 "%s failure keeps the thread and message visible and emits failure items",
12981470 (failurePoint) =>
......@@ -1799,6 +1971,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch
17991971 }),
18001972 ),
18011973 ),
1974 retryPreparation: launches.retryPreparation,
18021975 }),
18031976 Effect.flip,
18041977 );
apps/server/src/orchestration-v2/ThreadLaunchService.ts+158-34
......@@ -39,6 +39,7 @@ import * as ServerSettings from "../serverSettings.ts";
3939import * as TextGeneration from "../textGeneration/TextGeneration.ts";
4040import * as CommandReceiptStore from "./CommandReceiptStore.ts";
4141import * as IdAllocator from "./IdAllocator.ts";
42import type * as Orchestrator from "./Orchestrator.ts";
4243import { makeProviderFailure } from "./ProviderFailure.ts";
4344import { randomUuidV4 } from "./RandomUuid.ts";
4445import * as ThreadManagement from "./ThreadManagementService.ts";
......@@ -90,6 +91,25 @@ export interface ThreadLaunchInput {
9091 readonly creationSource: OrchestrationV2CreationSource;
9192}
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
93113export interface ThreadLaunchResult {
94114 readonly threadId: ThreadId;
95115 readonly projection: OrchestrationV2ThreadProjection;
......@@ -128,6 +148,10 @@ export class ThreadLaunchService extends Context.Service<
128148 readonly launch: (
129149 input: ThreadLaunchInput,
130150 ) => 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>;
131155 }
132156>()("t3/orchestration-v2/ThreadLaunchService") {}
133157
......@@ -161,7 +185,7 @@ const make = Effect.gen(function* () {
161185 yield* Effect.addFinalizer(() => Scope.close(preparationScope, Exit.void));
162186
163187 const mapError =
164 (input: ThreadLaunchInput, operation: ThreadLaunchError["operation"], threadId?: ThreadId) =>
188 (input: PreparationInput, operation: ThreadLaunchError["operation"], threadId?: ThreadId) =>
165189 (cause: unknown) =>
166190 new ThreadLaunchError({
167191 operation,
......@@ -201,7 +225,7 @@ const make = Effect.gen(function* () {
201225 });
202226
203227 const prepareInBackground = Effect.fn("ThreadLaunchService.prepareInBackground")(function* (
204 input: ThreadLaunchInput,
228 input: PreparationInput,
205229 threadId: ThreadId,
206230 runId: RunId | null,
207231 ) {
......@@ -216,10 +240,12 @@ const make = Effect.gen(function* () {
216240 ),
217241 );
218242
219 const tracked = input.workspaceStrategy.type === "worktree";
243 const reused = input.reusedWorktree;
244 const tracked = input.workspaceStrategy.type === "worktree" || reused !== undefined;
220245 let createdWorktreePath: string | null = null;
221246 let setupTerminalId: string | null = null;
222 if (tracked) {
247 let workspaceRecorded = false;
248 if (input.workspaceStrategy.type === "worktree") {
223249 yield* setupTracker.begin({
224250 threadId,
225251 branch: input.workspaceStrategy.branch ?? null,
......@@ -227,6 +253,14 @@ const make = Effect.gen(function* () {
227253 stages: ["fetch", "checkout", "setup-script", "agent"],
228254 fiber: yield* Effect.fiber,
229255 });
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 });
230264 }
231265 yield* Effect.gen(function* () {
232266 const initialMessage = input.initialMessage;
......@@ -358,21 +392,27 @@ const make = Effect.gen(function* () {
358392 yield* setupTracker.stageStatus(threadId, "checkout", "done");
359393 }
360394
361 yield* threads
362 .dispatch({
363 type: "thread.metadata.update",
364 commandId: CommandId.make(`${input.commandId}:workspace`),
365 threadId,
366 branch,
367 worktreePath,
368 })
369 .pipe(Effect.mapError(mapError(input, "update-thread", threadId)));
395 // A reused worktree is already recorded, and rewriting it could undo
396 // the first attempt's branch rename.
397 if (reused === undefined) {
398 yield* threads
399 .dispatch({
400 type: "thread.metadata.update",
401 commandId: CommandId.make(`${input.commandId}:workspace`),
402 threadId,
403 branch,
404 worktreePath,
405 })
406 .pipe(Effect.mapError(mapError(input, "update-thread", threadId)));
407 }
408 workspaceRecorded = true;
370409
371410 // Rename temporary branches (server-invented above, or sent by clients
372411 // that name worktrees themselves) in the background so generation latency
373412 // never delays provisioning or the provider turn. The temporary name
374413 // simply sticks if generation or the rename fails.
375414 if (
415 reused === undefined &&
376416 worktreePath !== null &&
377417 branch !== null &&
378418 initialMessage !== undefined &&
......@@ -512,27 +552,41 @@ const make = Effect.gen(function* () {
512552 cancelled ? "cancelled" : "failed",
513553 cancelled ? null : failureDetail(Cause.squash(cause)),
514554 );
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)) {
516559 if (setupTerminalId)
517560 yield* terminals
518561 .close({ threadId, terminalId: setupTerminalId, deleteHistory: true })
519562 .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.
520567 yield* git
521 .removeWorktree({
522 cwd: project.workspaceRoot,
523 path: createdWorktreePath,
524 force: true,
525 })
526 .pipe(Effect.ignore);
527 yield* threads
528 .dispatch({
529 type: "thread.metadata.update",
530 commandId: CommandId.make(`${input.commandId}:cancel-workspace`),
531 threadId,
532 worktreePath: null,
533 branch: null,
534 })
535 .pipe(Effect.ignore);
568 .removeWorktree({ cwd: project.workspaceRoot, path: removedPath, force: true })
569 .pipe(
570 Effect.andThen(
571 threads
572 .dispatch({
573 type: "thread.metadata.update",
574 commandId: CommandId.make(`${input.commandId}:cancel-workspace`),
575 threadId,
576 worktreePath: null,
577 branch: null,
578 })
579 .pipe(Effect.ignore),
580 ),
581 Effect.catchCause((removeCause) =>
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 );
536590 }
537591 }),
538592 ),
......@@ -540,7 +594,7 @@ const make = Effect.gen(function* () {
540594 });
541595
542596 const failPreparedRun = (
543 input: ThreadLaunchInput,
597 input: Pick<PreparationInput, "commandId">,
544598 threadId: ThreadId,
545599 runId: RunId | null,
546600 cause: unknown,
......@@ -565,7 +619,6 @@ const make = Effect.gen(function* () {
565619 }),
566620 })
567621 .pipe(
568 Effect.mapError(mapError(input, "fail-run", threadId)),
569622 Effect.catchCause((persistCause) =>
570623 Effect.logWarning("Failed to persist thread workspace preparation failure", {
571624 commandId: input.commandId,
......@@ -592,7 +645,7 @@ const make = Effect.gen(function* () {
592645 });
593646
594647 const schedulePreparation = Effect.fn("ThreadLaunchService.schedulePreparation")(function* (
595 input: ThreadLaunchInput,
648 input: PreparationInput,
596649 threadId: ThreadId,
597650 runId: RunId | null,
598651 ) {
......@@ -763,7 +816,7 @@ const make = Effect.gen(function* () {
763816 ...(input.initialMessage.context ? { context: input.initialMessage.context } : {}),
764817 ...(input.generateTitle === true ? { titleSeed: input.title } : {}),
765818 modelSelection: input.modelSelection,
766 dispatchMode: { type: "defer_start" },
819 dispatchMode: { type: "defer_start", workspaceStrategy },
767820 createdBy: input.createdBy,
768821 creationSource: input.creationSource,
769822 })
......@@ -836,7 +889,78 @@ const make = Effect.gen(function* () {
836889 },
837890 );
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 });
840964});
841965
842966export 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
165165 case "prepared-run.release":
166166 case "prepared-run.progress":
167167 case "prepared-run.fail":
168 case "prepared-run.retry":
168169 case "run.interrupt":
169170 case "queued-message.promote-to-steer":
170171 case "queue.resume":
apps/server/src/ws.ts+10-5
......@@ -1796,11 +1796,16 @@ const makeWsRpcLayer = (
17961796 ORCHESTRATION_V2_WS_METHODS.dispatchCommand,
17971797 startup
17981798 .enqueueCommand(
1799 ThreadMessageIntake.dispatchCommand(
1800 ThreadManagementService.withCreationProvenance(command, {
1801 createdBy: "user",
1802 creationSource: "creationSource" in command ? command.creationSource : "web",
1803 }),
1799 // A retry also restarts the preparation work the launch owns.
1800 (command.type === "prepared-run.retry"
1801 ? threadLaunch.retryPreparation(command)
1802 : ThreadMessageIntake.dispatchCommand(
1803 ThreadManagementService.withCreationProvenance(command, {
1804 createdBy: "user",
1805 creationSource:
1806 "creationSource" in command ? command.creationSource : "web",
1807 }),
1808 )
18041809 ).pipe(Effect.provide(intakeContext)),
18051810 )
18061811 .pipe(
apps/web/src/components/ChatView.tsx+35
......@@ -395,6 +395,7 @@ import {
395395} from "../state/server";
396396import { terminalEnvironment } from "../state/terminal";
397397import { threadEnvironment } from "../state/threads";
398import { workspacePreparationRetryRunIds } from "@t3tools/client-runtime/state/turn-item-presentation";
398399import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
399400import { vcsEnvironment } from "../state/vcs";
400401import { sourceControlEnvironment } from "../state/sourceControl";
......@@ -1510,6 +1511,9 @@ function releaseChatTimelineAnchor<T extends { readonly messageId: MessageId | n
15101511 return current.messageId === null ? current : { ...current, messageId: null };
15111512}
15121513
1514/** Runs with a workspace preparation retry in flight, across ChatView instances. */
1515const retryingWorkspacePreparationRunIds = new Set<RunId>();
1516
15131517export default function ChatView(props: ChatViewProps) {
15141518 const {
15151519 environmentId,
......@@ -3920,6 +3924,34 @@ export default function ChatView(props: ChatViewProps) {
39203924 const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, {
39213925 reportFailure: false,
39223926 });
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 );
39233955 const onCancelWorktreeSetup = useCallback(() => {
39243956 if (!worktreeSetup || worktreeSetup.phase !== "running") return;
39253957 void cancelWorktreeSetup({
......@@ -10896,6 +10928,9 @@ export default function ChatView(props: ChatViewProps) {
1089610928 activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt}
1089710929 worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup}
1089810930 onCancelWorktreeSetup={onCancelWorktreeSetup}
10931 {...(paintOnlyDisplayedTimeline
10932 ? {}
10933 : { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation })}
1089910934 {...(draftId ? { onWorktreeSetupWorkLocally } : {})}
1090010935 {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})}
1090110936 isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree}
apps/web/src/components/chat/MessagesTimeline.tsx+34
......@@ -18,6 +18,7 @@ import {
1818} from "./timelineMinimapItems";
1919import {
2020 COMPOSER_CONTEXT_KINDS,
21 ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE,
2122 type AssistantCitation,
2223 type EnvironmentId,
2324 type MessageId,
......@@ -139,6 +140,7 @@ import {
139140 WrenchIcon,
140141 XIcon,
141142 ZapIcon,
143 RotateCcwIcon,
142144} from "lucide-react";
143145import { ChevronDown, ChevronRight } from "lucide";
144146import type {
......@@ -325,6 +327,8 @@ interface TimelineRowSharedState {
325327 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
326328 onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void;
327329 onCancelWorktreeSetup: (() => void) | null;
330 retryableWorkspacePreparationRunIds: ReadonlySet<RunId>;
331 onRetryWorkspacePreparation: ((runId: RunId) => void) | null;
328332 onWorktreeSetupWorkLocally: (() => void) | null;
329333 onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null;
330334 workGroupViewState: WorkGroupViewState;
......@@ -383,6 +387,7 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = {
383387 },
384388} as const satisfies MaintainScrollAtEndOptions;
385389const EMPTY_TIMELINE_RUNS: ReadonlyArray<HandoffTimelineRun> = [];
390const EMPTY_RUN_IDS: ReadonlySet<RunId> = new Set();
386391// Streamed text lands a paragraph at a time. A smooth scroll to the end
387392// turns each landing into a short glide instead of a jump. Thread switches
388393// and layout settles keep the instant variant so nothing visibly travels.
......@@ -416,6 +421,9 @@ interface MessagesTimelineProps {
416421 activeTurnStartedAt?: string | null;
417422 worktreeSetup?: WorktreeSetupSnapshot | null;
418423 onCancelWorktreeSetup?: () => void;
424 /** Runs whose failed workspace preparation can be retried, keyed by run id. */
425 retryableWorkspacePreparationRunIds?: ReadonlySet<RunId>;
426 onRetryWorkspacePreparation?: (runId: RunId) => void;
419427 onWorktreeSetupWorkLocally?: () => void;
420428 onOpenWorktreeSetupTerminal?: (terminalId: string) => void;
421429 isPreparingWorktree?: boolean;
......@@ -499,6 +507,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
499507 activeTurnStartedAt = null,
500508 worktreeSetup = null,
501509 onCancelWorktreeSetup,
510 retryableWorkspacePreparationRunIds = EMPTY_RUN_IDS,
511 onRetryWorkspacePreparation,
502512 onWorktreeSetupWorkLocally,
503513 onOpenWorktreeSetupTerminal,
504514 isPreparingWorktree = false,
......@@ -1164,6 +1174,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
11641174 onToggleWorkGroup,
11651175 onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure,
11661176 onCancelWorktreeSetup: onCancelWorktreeSetup ?? null,
1177 retryableWorkspacePreparationRunIds,
1178 onRetryWorkspacePreparation: onRetryWorkspacePreparation ?? null,
11671179 onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null,
11681180 onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null,
11691181 workGroupViewState,
......@@ -1197,6 +1209,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
11971209 onToggleWorkGroup,
11981210 suspendEndScrollMaintenanceForDisclosure,
11991211 onCancelWorktreeSetup,
1212 retryableWorkspacePreparationRunIds,
1213 onRetryWorkspacePreparation,
12001214 onWorktreeSetupWorkLocally,
12011215 onOpenWorktreeSetupTerminal,
12021216 workGroupViewState,
......@@ -4980,6 +4994,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
49804994 const { workEntry, workspaceRoot, displayLabel } = props;
49814995 const ctx = use(TimelineRowCtx);
49824996 const { threadRef, onImageExpand, timestampFormat } = ctx;
4997 const { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation } = ctx;
49834998 const createdThread =
49844999 workEntry.projectedItem?.item.type === "thread_created"
49855000 ? workEntry.projectedItem.item
......@@ -5011,6 +5026,12 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
50115026 const label = warning
50125027 ? `Usage limit reached.${resetTime ? ` Retry after ${resetTime}.` : ""}`
50135028 : 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;
50145035 return (
50155036 <WorkLogRow
50165037 data-v2-item-type="error"
......@@ -5041,6 +5062,19 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
50415062 {failureItem.failure.message}
50425063 </p>
50435064 ) : 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}
50445078 </WorkLogRow>
50455079 );
50465080 }
packages/client-runtime/src/operations/commands.ts+15
......@@ -237,6 +237,10 @@ export interface CancelQueuedRunInput extends ThreadCommandInput {
237237 readonly runId: RunId;
238238}
239239
240export interface RetryWorkspacePreparationInput extends ThreadCommandInput {
241 readonly runId: RunId;
242}
243
240244export interface EditQueuedRunInput extends ThreadCommandInput {
241245 readonly runId: RunId;
242246 readonly text: string;
......@@ -982,6 +986,17 @@ export const cancelQueuedRun = Effect.fn("EnvironmentCommands.cancelQueuedRun")(
982986 });
983987});
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
9851000export const editQueuedRun = Effect.fn("EnvironmentCommands.editQueuedRun")(function* (
9861001 input: EditQueuedRunInput,
9871002) {
packages/client-runtime/src/state/threadCommands.ts+8
......@@ -21,6 +21,7 @@ import {
2121 type ThreadCommandInput,
2222 type ArchiveThreadInput,
2323 type CancelQueuedRunInput,
24 type RetryWorkspacePreparationInput,
2425 type CreateThreadInput,
2526 type DeleteThreadInput,
2627 type EditQueuedRunInput,
......@@ -65,6 +66,7 @@ import {
6566 promoteQueuedRun,
6667 reorderQueuedRun,
6768 resumeThreadQueue,
69 retryWorkspacePreparation,
6870 linkThreadPullRequest,
6971 respondToThreadApproval,
7072 respondToThreadUserInput,
......@@ -357,6 +359,12 @@ export function createThreadEnvironmentAtoms<R, E>(
357359 scheduler,
358360 concurrency,
359361 }),
362 retryWorkspacePreparation: createEnvironmentCommand(runtime, {
363 label: "environment-data:commands:thread:retry-workspace-preparation",
364 execute: (input: RetryWorkspacePreparationInput) => retryWorkspacePreparation(input),
365 scheduler,
366 concurrency,
367 }),
360368 editQueuedRun: createEnvironmentCommand(runtime, {
361369 label: "environment-data:commands:thread:edit-queued-run",
362370 execute: (input: EditQueuedRunInput) => editQueuedRun(input),
packages/client-runtime/src/state/turnItemPresentation.test.ts+70-2
......@@ -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";
29import * as DateTime from "effect/DateTime";
310import { describe, expect, it } from "vite-plus/test";
411
5import { turnItemIsWorkspacePreparation } from "./turnItemPresentation.ts";
12import {
13 turnItemIsWorkspacePreparation,
14 workspacePreparationRetryRunIds,
15} from "./turnItemPresentation.ts";
616
717function command(input: string): OrchestrationV2TurnItem {
818 const now = DateTime.makeUnsafe("2026-08-03T00:00:00.000Z");
......@@ -28,9 +38,67 @@ function command(input: string): OrchestrationV2TurnItem {
2838 };
2939}
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
3173describe("turnItemIsWorkspacePreparation", () => {
3274 it("identifies the synthetic workspace preparation command", () => {
3375 expect(turnItemIsWorkspacePreparation(command("Preparing workspace"))).toBe(true);
3476 expect(turnItemIsWorkspacePreparation(command("prepare workspace"))).toBe(false);
3577 });
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 });
36104});
packages/client-runtime/src/state/turnItemPresentation.ts+44-3
......@@ -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
37const 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 */
613export 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;
849}
packages/contracts/src/orchestrationV2.ts+36-21
......@@ -508,6 +508,29 @@ export const OrchestrationV2RunBackgroundWorkCancelled = Schema.Struct({
508508export type OrchestrationV2RunBackgroundWorkCancelled =
509509 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
511534export const OrchestrationV2Run = Schema.Struct({
512535 id: RunId,
513536 threadId: ThreadId,
......@@ -549,6 +572,8 @@ export const OrchestrationV2Run = Schema.Struct({
549572 }),
550573 ),
551574 delegatedCompletion: Schema.optional(OrchestrationV2DelegatedCompletionCohort),
575 /** How a launch prepares this run's workspace; prepared-run.retry repeats it. */
576 workspacePreparation: Schema.optional(OrchestrationV2ThreadLaunchWorkspaceStrategy),
552577});
553578export type OrchestrationV2Run = typeof OrchestrationV2Run.Type;
554579
......@@ -2691,7 +2716,10 @@ export const OrchestrationV2Command = Schema.Union([
26912716 }),
26922717 ),
26932718 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 }),
26952723 Schema.Struct({ type: Schema.Literal("steer_active"), targetRunId: RunId }),
26962724 Schema.Struct({ type: Schema.Literal("restart_active"), targetRunId: RunId }),
26972725 Schema.Struct({ type: Schema.Literal("queue_after_active") }),
......@@ -2725,6 +2753,13 @@ export const OrchestrationV2Command = Schema.Union([
27252753 runId: RunId,
27262754 failure: OrchestrationV2ProviderFailure,
27272755 }),
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 }),
27282763 Schema.Struct({
27292764 type: Schema.Literal("run.interrupt"),
27302765 commandId: CommandId,
......@@ -2961,26 +2996,6 @@ export const OrchestrationV2ArchivedShellStreamItem = Schema.Union([
29612996export type OrchestrationV2ArchivedShellStreamItem =
29622997 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
29842999export const OrchestrationV2ThreadLaunchInput = Schema.Struct({
29853000 commandId: CommandId,
29863001 creationSource: Schema.optional(OrchestrationV2CreationSource),