| author | |
| committer | |
| log | 737993303d36e10674c54b95e5bd3826682c99c7 |
| tree | e626212a0f9d26797f88436a58ee06eaf3966add |
| parent | 1826fb55cc4c9c6246d31175e15a496ba4778e07 |
| signature | Signed by PGP key B5690EEEBB952194 |
13 files changed, 752 insertions(+), 70 deletions(-)
apps/mobile/src/features/threads/thread-work-log.tsx+57-1| ... | ... | @@ -41,7 +41,17 @@ import { |
| 41 | 41 | View, |
| 42 | 42 | } from "react-native"; |
| 43 | 43 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| 44 | import type { EnvironmentId, ToolActivityIcon } from "@t3tools/contracts"; | |
| 44 | import { | |
| 45 | ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, | |
| 46 | type EnvironmentId, | |
| 47 | type RunId, | |
| 48 | type ThreadId, | |
| 49 | type ToolActivityIcon, | |
| 50 | } from "@t3tools/contracts"; | |
| 51 | import { useAtomValue } from "@effect/atom-react"; | |
| 52 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 53 | import { environmentThreadDetails, threadEnvironment } from "../../state/threads"; | |
| 54 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 45 | 55 | import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; |
| 46 | 56 | |
| 47 | 57 | import { AppText as Text } from "../../components/AppText"; |
| ... | ... | @@ -770,6 +780,44 @@ function workLogRowKey(row: ThreadFeedActivity): string { |
| 770 | 780 | return row.id; |
| 771 | 781 | } |
| 772 | 782 | |
| 783 | /** Shown while the thread's run still ends in this failed preparation. */ | |
| 784 | function 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 | ||
| 773 | 821 | const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 774 | 822 | props: Omit< |
| 775 | 823 | ThreadWorkLogProps, |
| ... | ... | @@ -850,6 +898,14 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 850 | 898 | {failureItem.failure.message} |
| 851 | 899 | </Text> |
| 852 | 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 | 909 | </View> |
| 854 | 910 | </WorkLogPressable> |
| 855 | 911 | ); |
apps/server/src/orchestration-v2/Orchestrator.ts+110-3| ... | ... | @@ -12,6 +12,7 @@ import { |
| 12 | 12 | legacyThreadPullRequestKey, |
| 13 | 13 | } from "@t3tools/shared/threadPullRequests"; |
| 14 | 14 | import { |
| 15 | ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, | |
| 15 | 16 | type ChatAttachment, |
| 16 | 17 | CommandId, |
| 17 | 18 | isProviderNativeSubagentThread, |
| ... | ... | @@ -400,6 +401,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { |
| 400 | 401 | case "prepared-run.release": |
| 401 | 402 | case "prepared-run.progress": |
| 402 | 403 | case "prepared-run.fail": |
| 404 | case "prepared-run.retry": | |
| 403 | 405 | case "run.interrupt": |
| 404 | 406 | case "queued-message.promote-to-steer": |
| 405 | 407 | case "queue.resume": |
| ... | ... | @@ -442,6 +444,14 @@ function pendingThreadTitleGenerationEffect( |
| 442 | 444 | |
| 443 | 445 | const WORKSPACE_PREPARATION_INPUT = "Preparing workspace"; |
| 444 | 446 | |
| 447 | /** A reopened preparation item drops the output and exit code of the attempt it replaces. */ | |
| 448 | function 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 | ||
| 445 | 455 | function isBlockingRun(run: OrchestrationV2Run): boolean { |
| 446 | 456 | return ( |
| 447 | 457 | run.status === "preparing" || |
| ... | ... | @@ -5150,6 +5160,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 5150 | 5160 | ...(command.restartContinuationOfRunId === undefined |
| 5151 | 5161 | ? {} |
| 5152 | 5162 | : { restartContinuationOfRunId: command.restartContinuationOfRunId }), |
| 5163 | ...(dispatchMode.type === "defer_start" && dispatchMode.workspaceStrategy !== undefined | |
| 5164 | ? { workspacePreparation: dispatchMode.workspaceStrategy } | |
| 5165 | : {}), | |
| 5153 | 5166 | ...wakeWorkStartedAt(projection.runs, command), |
| 5154 | 5167 | }; |
| 5155 | 5168 | const attempt: OrchestrationV2RunAttempt = { |
| ... | ... | @@ -7489,7 +7502,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 7489 | 7502 | command: Extract< |
| 7490 | 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 | 7512 | projection: Pick< |
| ... | ... | @@ -7512,7 +7529,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 7512 | 7529 | candidate.input === WORKSPACE_PREPARATION_INPUT, |
| 7513 | 7530 | ); |
| 7514 | 7531 | if ( |
| 7515 | run?.status !== "preparing" || | |
| 7532 | run?.status !== (command.type === "prepared-run.retry" ? "failed" : "preparing") || | |
| 7516 | 7533 | attempt === undefined || |
| 7517 | 7534 | rootNode === undefined || |
| 7518 | 7535 | providerThread === undefined || |
| ... | ... | @@ -7730,7 +7747,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 7730 | 7747 | completedAt: now, |
| 7731 | 7748 | updatedAt: now, |
| 7732 | 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 | 7756 | yield* emitEvent({ |
| ... | ... | @@ -7744,6 +7764,90 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 7744 | 7764 | }); |
| 7745 | 7765 | }); |
| 7746 | 7766 | |
| 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 | 7852 | * Ends the background work a settled thread still shows that no provider |
| 7749 | 7853 | * 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 | 9554 | case "prepared-run.fail": |
| 9451 | 9555 | yield* dispatchPreparedRunFail(command, events); |
| 9452 | 9556 | break; |
| 9557 | case "prepared-run.retry": | |
| 9558 | yield* dispatchPreparedRunRetry(command, events); | |
| 9559 | break; | |
| 9453 | 9560 | case "runtime-request.respond": |
| 9454 | 9561 | yield* dispatchRuntimeRequestRespond(command, events, effects); |
| 9455 | 9562 | break; |
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+174-1| ... | ... | @@ -10,6 +10,7 @@ import * as ThreadMessageIntake from "./ThreadMessageIntake.ts"; |
| 10 | 10 | import { assert, it, vi } from "@effect/vitest"; |
| 11 | 11 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 12 | 12 | import { |
| 13 | ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, | |
| 13 | 14 | ChatAttachmentId, |
| 14 | 15 | ComposerContextId, |
| 15 | 16 | type ChatAttachment, |
| ... | ... | @@ -127,6 +128,10 @@ function makeHarness(options: HarnessOptions = {}) { |
| 127 | 128 | const renameBranch = vi.fn( |
| 128 | 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 | 135 | const runSetup = vi.fn( |
| 131 | 136 | options.runSetup ?? (() => Effect.succeed({ status: "no-script" as const })), |
| 132 | 137 | ); |
| ... | ... | @@ -164,7 +169,7 @@ function makeHarness(options: HarnessOptions = {}) { |
| 164 | 169 | fetchRemote: options.fetchRemote ?? (() => Effect.void), |
| 165 | 170 | remoteExists: () => Effect.succeed(true), |
| 166 | 171 | remoteBranchExists: () => Effect.succeed(true), |
| 167 | removeWorktree: () => Effect.void, | |
| 172 | removeWorktree, | |
| 168 | 173 | resolveRemoteTrackingCommit: () => |
| 169 | 174 | Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" }), |
| 170 | 175 | }), |
| ... | ... | @@ -220,6 +225,7 @@ function makeHarness(options: HarnessOptions = {}) { |
| 220 | 225 | externalServices, |
| 221 | 226 | ), |
| 222 | 227 | createWorktree, |
| 228 | removeWorktree, | |
| 223 | 229 | renameBranch, |
| 224 | 230 | generateBranchName, |
| 225 | 231 | generateThreadTitle, |
| ... | ... | @@ -1293,6 +1299,172 @@ it.effect("shows the fetch diagnosis when preparing a worktree from origin fails |
| 1293 | 1299 | }).pipe(Effect.provide(harness.layer)); |
| 1294 | 1300 | }); |
| 1295 | 1301 | |
| 1302 | it.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 | ||
| 1372 | it.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 | ||
| 1434 | it.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 | ||
| 1296 | 1468 | it.effect.each(["worktree", "setup"] as const)( |
| 1297 | 1469 | "%s failure keeps the thread and message visible and emits failure items", |
| 1298 | 1470 | (failurePoint) => |
| ... | ... | @@ -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 | 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 | 39 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 40 | 40 | import * as CommandReceiptStore from "./CommandReceiptStore.ts"; |
| 41 | 41 | import * as IdAllocator from "./IdAllocator.ts"; |
| 42 | import type * as Orchestrator from "./Orchestrator.ts"; | |
| 42 | 43 | import { makeProviderFailure } from "./ProviderFailure.ts"; |
| 43 | 44 | import { randomUuidV4 } from "./RandomUuid.ts"; |
| 44 | 45 | import * as ThreadManagement from "./ThreadManagementService.ts"; |
| ... | ... | @@ -90,6 +91,25 @@ export interface ThreadLaunchInput { |
| 90 | 91 | readonly creationSource: OrchestrationV2CreationSource; |
| 91 | 92 | } |
| 92 | 93 | |
| 94 | /** What workspace preparation reads from a launch; a retry rebuilds it from the run. */ | |
| 95 | type 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 | ||
| 107 | export interface ThreadLaunchRetryInput { | |
| 108 | readonly commandId: CommandId; | |
| 109 | readonly threadId: ThreadId; | |
| 110 | readonly runId: RunId; | |
| 111 | } | |
| 112 | ||
| 93 | 113 | export interface ThreadLaunchResult { |
| 94 | 114 | readonly threadId: ThreadId; |
| 95 | 115 | readonly projection: OrchestrationV2ThreadProjection; |
| ... | ... | @@ -128,6 +148,10 @@ export class ThreadLaunchService extends Context.Service< |
| 128 | 148 | readonly launch: ( |
| 129 | 149 | input: ThreadLaunchInput, |
| 130 | 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 | 156 | >()("t3/orchestration-v2/ThreadLaunchService") {} |
| 133 | 157 | |
| ... | ... | @@ -161,7 +185,7 @@ const make = Effect.gen(function* () { |
| 161 | 185 | yield* Effect.addFinalizer(() => Scope.close(preparationScope, Exit.void)); |
| 162 | 186 | |
| 163 | 187 | const mapError = |
| 164 | (input: ThreadLaunchInput, operation: ThreadLaunchError["operation"], threadId?: ThreadId) => | |
| 188 | (input: PreparationInput, operation: ThreadLaunchError["operation"], threadId?: ThreadId) => | |
| 165 | 189 | (cause: unknown) => |
| 166 | 190 | new ThreadLaunchError({ |
| 167 | 191 | operation, |
| ... | ... | @@ -201,7 +225,7 @@ const make = Effect.gen(function* () { |
| 201 | 225 | }); |
| 202 | 226 | |
| 203 | 227 | const prepareInBackground = Effect.fn("ThreadLaunchService.prepareInBackground")(function* ( |
| 204 | input: ThreadLaunchInput, | |
| 228 | input: PreparationInput, | |
| 205 | 229 | threadId: ThreadId, |
| 206 | 230 | runId: RunId | null, |
| 207 | 231 | ) { |
| ... | ... | @@ -216,10 +240,12 @@ const make = Effect.gen(function* () { |
| 216 | 240 | ), |
| 217 | 241 | ); |
| 218 | 242 | |
| 219 | const tracked = input.workspaceStrategy.type === "worktree"; | |
| 243 | const reused = input.reusedWorktree; | |
| 244 | const tracked = input.workspaceStrategy.type === "worktree" || reused !== undefined; | |
| 220 | 245 | let createdWorktreePath: string | null = null; |
| 221 | 246 | let setupTerminalId: string | null = null; |
| 222 | if (tracked) { | |
| 247 | let workspaceRecorded = false; | |
| 248 | if (input.workspaceStrategy.type === "worktree") { | |
| 223 | 249 | yield* setupTracker.begin({ |
| 224 | 250 | threadId, |
| 225 | 251 | branch: input.workspaceStrategy.branch ?? null, |
| ... | ... | @@ -227,6 +253,14 @@ const make = Effect.gen(function* () { |
| 227 | 253 | stages: ["fetch", "checkout", "setup-script", "agent"], |
| 228 | 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 | 265 | yield* Effect.gen(function* () { |
| 232 | 266 | const initialMessage = input.initialMessage; |
| ... | ... | @@ -358,21 +392,27 @@ const make = Effect.gen(function* () { |
| 358 | 392 | yield* setupTracker.stageStatus(threadId, "checkout", "done"); |
| 359 | 393 | } |
| 360 | 394 | |
| 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; | |
| 370 | 409 | |
| 371 | 410 | // Rename temporary branches (server-invented above, or sent by clients |
| 372 | 411 | // that name worktrees themselves) in the background so generation latency |
| 373 | 412 | // never delays provisioning or the provider turn. The temporary name |
| 374 | 413 | // simply sticks if generation or the rename fails. |
| 375 | 414 | if ( |
| 415 | reused === undefined && | |
| 376 | 416 | worktreePath !== null && |
| 377 | 417 | branch !== null && |
| 378 | 418 | initialMessage !== undefined && |
| ... | ... | @@ -512,27 +552,41 @@ const make = Effect.gen(function* () { |
| 512 | 552 | cancelled ? "cancelled" : "failed", |
| 513 | 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 | 559 | if (setupTerminalId) |
| 517 | 560 | yield* terminals |
| 518 | 561 | .close({ threadId, terminalId: setupTerminalId, deleteHistory: true }) |
| 519 | 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 | 567 | 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 | ); | |
| 536 | 590 | } |
| 537 | 591 | }), |
| 538 | 592 | ), |
| ... | ... | @@ -540,7 +594,7 @@ const make = Effect.gen(function* () { |
| 540 | 594 | }); |
| 541 | 595 | |
| 542 | 596 | const failPreparedRun = ( |
| 543 | input: ThreadLaunchInput, | |
| 597 | input: Pick<PreparationInput, "commandId">, | |
| 544 | 598 | threadId: ThreadId, |
| 545 | 599 | runId: RunId | null, |
| 546 | 600 | cause: unknown, |
| ... | ... | @@ -565,7 +619,6 @@ const make = Effect.gen(function* () { |
| 565 | 619 | }), |
| 566 | 620 | }) |
| 567 | 621 | .pipe( |
| 568 | Effect.mapError(mapError(input, "fail-run", threadId)), | |
| 569 | 622 | Effect.catchCause((persistCause) => |
| 570 | 623 | Effect.logWarning("Failed to persist thread workspace preparation failure", { |
| 571 | 624 | commandId: input.commandId, |
| ... | ... | @@ -592,7 +645,7 @@ const make = Effect.gen(function* () { |
| 592 | 645 | }); |
| 593 | 646 | |
| 594 | 647 | const schedulePreparation = Effect.fn("ThreadLaunchService.schedulePreparation")(function* ( |
| 595 | input: ThreadLaunchInput, | |
| 648 | input: PreparationInput, | |
| 596 | 649 | threadId: ThreadId, |
| 597 | 650 | runId: RunId | null, |
| 598 | 651 | ) { |
| ... | ... | @@ -763,7 +816,7 @@ const make = Effect.gen(function* () { |
| 763 | 816 | ...(input.initialMessage.context ? { context: input.initialMessage.context } : {}), |
| 764 | 817 | ...(input.generateTitle === true ? { titleSeed: input.title } : {}), |
| 765 | 818 | modelSelection: input.modelSelection, |
| 766 | dispatchMode: { type: "defer_start" }, | |
| 819 | dispatchMode: { type: "defer_start", workspaceStrategy }, | |
| 767 | 820 | createdBy: input.createdBy, |
| 768 | 821 | creationSource: input.creationSource, |
| 769 | 822 | }) |
| ... | ... | @@ -836,7 +889,78 @@ const make = Effect.gen(function* () { |
| 836 | 889 | }, |
| 837 | 890 | ); |
| 838 | 891 | |
| 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 | }); |
| 841 | 965 | |
| 842 | 966 | export 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 | 165 | case "prepared-run.release": |
| 166 | 166 | case "prepared-run.progress": |
| 167 | 167 | case "prepared-run.fail": |
| 168 | case "prepared-run.retry": | |
| 168 | 169 | case "run.interrupt": |
| 169 | 170 | case "queued-message.promote-to-steer": |
| 170 | 171 | case "queue.resume": |
apps/server/src/ws.ts+10-5| ... | ... | @@ -1796,11 +1796,16 @@ const makeWsRpcLayer = ( |
| 1796 | 1796 | ORCHESTRATION_V2_WS_METHODS.dispatchCommand, |
| 1797 | 1797 | startup |
| 1798 | 1798 | .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 | ) | |
| 1804 | 1809 | ).pipe(Effect.provide(intakeContext)), |
| 1805 | 1810 | ) |
| 1806 | 1811 | .pipe( |
apps/web/src/components/ChatView.tsx+35| ... | ... | @@ -395,6 +395,7 @@ import { |
| 395 | 395 | } from "../state/server"; |
| 396 | 396 | import { terminalEnvironment } from "../state/terminal"; |
| 397 | 397 | import { threadEnvironment } from "../state/threads"; |
| 398 | import { workspacePreparationRetryRunIds } from "@t3tools/client-runtime/state/turn-item-presentation"; | |
| 398 | 399 | import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; |
| 399 | 400 | import { vcsEnvironment } from "../state/vcs"; |
| 400 | 401 | import { sourceControlEnvironment } from "../state/sourceControl"; |
| ... | ... | @@ -1510,6 +1511,9 @@ function releaseChatTimelineAnchor<T extends { readonly messageId: MessageId | n |
| 1510 | 1511 | return current.messageId === null ? current : { ...current, messageId: null }; |
| 1511 | 1512 | } |
| 1512 | 1513 | |
| 1514 | /** Runs with a workspace preparation retry in flight, across ChatView instances. */ | |
| 1515 | const retryingWorkspacePreparationRunIds = new Set<RunId>(); | |
| 1516 | ||
| 1513 | 1517 | export default function ChatView(props: ChatViewProps) { |
| 1514 | 1518 | const { |
| 1515 | 1519 | environmentId, |
| ... | ... | @@ -3920,6 +3924,34 @@ export default function ChatView(props: ChatViewProps) { |
| 3920 | 3924 | const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { |
| 3921 | 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 | 3955 | const onCancelWorktreeSetup = useCallback(() => { |
| 3924 | 3956 | if (!worktreeSetup || worktreeSetup.phase !== "running") return; |
| 3925 | 3957 | void cancelWorktreeSetup({ |
| ... | ... | @@ -10896,6 +10928,9 @@ export default function ChatView(props: ChatViewProps) { |
| 10896 | 10928 | activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt} |
| 10897 | 10929 | worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup} |
| 10898 | 10930 | onCancelWorktreeSetup={onCancelWorktreeSetup} |
| 10931 | {...(paintOnlyDisplayedTimeline | |
| 10932 | ? {} | |
| 10933 | : { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation })} | |
| 10899 | 10934 | {...(draftId ? { onWorktreeSetupWorkLocally } : {})} |
| 10900 | 10935 | {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})} |
| 10901 | 10936 | isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree} |
apps/web/src/components/chat/MessagesTimeline.tsx+34| ... | ... | @@ -18,6 +18,7 @@ import { |
| 18 | 18 | } from "./timelineMinimapItems"; |
| 19 | 19 | import { |
| 20 | 20 | COMPOSER_CONTEXT_KINDS, |
| 21 | ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, | |
| 21 | 22 | type AssistantCitation, |
| 22 | 23 | type EnvironmentId, |
| 23 | 24 | type MessageId, |
| ... | ... | @@ -139,6 +140,7 @@ import { |
| 139 | 140 | WrenchIcon, |
| 140 | 141 | XIcon, |
| 141 | 142 | ZapIcon, |
| 143 | RotateCcwIcon, | |
| 142 | 144 | } from "lucide-react"; |
| 143 | 145 | import { ChevronDown, ChevronRight } from "lucide"; |
| 144 | 146 | import type { |
| ... | ... | @@ -325,6 +327,8 @@ interface TimelineRowSharedState { |
| 325 | 327 | onToggleWorkGroup: (groupId: string, anchorKey: string) => void; |
| 326 | 328 | onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void; |
| 327 | 329 | onCancelWorktreeSetup: (() => void) | null; |
| 330 | retryableWorkspacePreparationRunIds: ReadonlySet<RunId>; | |
| 331 | onRetryWorkspacePreparation: ((runId: RunId) => void) | null; | |
| 328 | 332 | onWorktreeSetupWorkLocally: (() => void) | null; |
| 329 | 333 | onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null; |
| 330 | 334 | workGroupViewState: WorkGroupViewState; |
| ... | ... | @@ -383,6 +387,7 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = { |
| 383 | 387 | }, |
| 384 | 388 | } as const satisfies MaintainScrollAtEndOptions; |
| 385 | 389 | const EMPTY_TIMELINE_RUNS: ReadonlyArray<HandoffTimelineRun> = []; |
| 390 | const EMPTY_RUN_IDS: ReadonlySet<RunId> = new Set(); | |
| 386 | 391 | // Streamed text lands a paragraph at a time. A smooth scroll to the end |
| 387 | 392 | // turns each landing into a short glide instead of a jump. Thread switches |
| 388 | 393 | // and layout settles keep the instant variant so nothing visibly travels. |
| ... | ... | @@ -416,6 +421,9 @@ interface MessagesTimelineProps { |
| 416 | 421 | activeTurnStartedAt?: string | null; |
| 417 | 422 | worktreeSetup?: WorktreeSetupSnapshot | null; |
| 418 | 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 | 427 | onWorktreeSetupWorkLocally?: () => void; |
| 420 | 428 | onOpenWorktreeSetupTerminal?: (terminalId: string) => void; |
| 421 | 429 | isPreparingWorktree?: boolean; |
| ... | ... | @@ -499,6 +507,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 499 | 507 | activeTurnStartedAt = null, |
| 500 | 508 | worktreeSetup = null, |
| 501 | 509 | onCancelWorktreeSetup, |
| 510 | retryableWorkspacePreparationRunIds = EMPTY_RUN_IDS, | |
| 511 | onRetryWorkspacePreparation, | |
| 502 | 512 | onWorktreeSetupWorkLocally, |
| 503 | 513 | onOpenWorktreeSetupTerminal, |
| 504 | 514 | isPreparingWorktree = false, |
| ... | ... | @@ -1164,6 +1174,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 1164 | 1174 | onToggleWorkGroup, |
| 1165 | 1175 | onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure, |
| 1166 | 1176 | onCancelWorktreeSetup: onCancelWorktreeSetup ?? null, |
| 1177 | retryableWorkspacePreparationRunIds, | |
| 1178 | onRetryWorkspacePreparation: onRetryWorkspacePreparation ?? null, | |
| 1167 | 1179 | onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null, |
| 1168 | 1180 | onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null, |
| 1169 | 1181 | workGroupViewState, |
| ... | ... | @@ -1197,6 +1209,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 1197 | 1209 | onToggleWorkGroup, |
| 1198 | 1210 | suspendEndScrollMaintenanceForDisclosure, |
| 1199 | 1211 | onCancelWorktreeSetup, |
| 1212 | retryableWorkspacePreparationRunIds, | |
| 1213 | onRetryWorkspacePreparation, | |
| 1200 | 1214 | onWorktreeSetupWorkLocally, |
| 1201 | 1215 | onOpenWorktreeSetupTerminal, |
| 1202 | 1216 | workGroupViewState, |
| ... | ... | @@ -4980,6 +4994,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { |
| 4980 | 4994 | const { workEntry, workspaceRoot, displayLabel } = props; |
| 4981 | 4995 | const ctx = use(TimelineRowCtx); |
| 4982 | 4996 | const { threadRef, onImageExpand, timestampFormat } = ctx; |
| 4997 | const { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation } = ctx; | |
| 4983 | 4998 | const createdThread = |
| 4984 | 4999 | workEntry.projectedItem?.item.type === "thread_created" |
| 4985 | 5000 | ? workEntry.projectedItem.item |
| ... | ... | @@ -5011,6 +5026,12 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { |
| 5011 | 5026 | const label = warning |
| 5012 | 5027 | ? `Usage limit reached.${resetTime ? ` Retry after ${resetTime}.` : ""}` |
| 5013 | 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 | 5035 | return ( |
| 5015 | 5036 | <WorkLogRow |
| 5016 | 5037 | data-v2-item-type="error" |
| ... | ... | @@ -5041,6 +5062,19 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { |
| 5041 | 5062 | {failureItem.failure.message} |
| 5042 | 5063 | </p> |
| 5043 | 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 | 5078 | </WorkLogRow> |
| 5045 | 5079 | ); |
| 5046 | 5080 | } |
packages/client-runtime/src/operations/commands.ts+15| ... | ... | @@ -237,6 +237,10 @@ export interface CancelQueuedRunInput extends ThreadCommandInput { |
| 237 | 237 | readonly runId: RunId; |
| 238 | 238 | } |
| 239 | 239 | |
| 240 | export interface RetryWorkspacePreparationInput extends ThreadCommandInput { | |
| 241 | readonly runId: RunId; | |
| 242 | } | |
| 243 | ||
| 240 | 244 | export interface EditQueuedRunInput extends ThreadCommandInput { |
| 241 | 245 | readonly runId: RunId; |
| 242 | 246 | readonly text: string; |
| ... | ... | @@ -982,6 +986,17 @@ export const cancelQueuedRun = Effect.fn("EnvironmentCommands.cancelQueuedRun")( |
| 982 | 986 | }); |
| 983 | 987 | }); |
| 984 | 988 | |
| 989 | export 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 | ||
| 985 | 1000 | export const editQueuedRun = Effect.fn("EnvironmentCommands.editQueuedRun")(function* ( |
| 986 | 1001 | input: EditQueuedRunInput, |
| 987 | 1002 | ) { |
packages/client-runtime/src/state/threadCommands.ts+8| ... | ... | @@ -21,6 +21,7 @@ import { |
| 21 | 21 | type ThreadCommandInput, |
| 22 | 22 | type ArchiveThreadInput, |
| 23 | 23 | type CancelQueuedRunInput, |
| 24 | type RetryWorkspacePreparationInput, | |
| 24 | 25 | type CreateThreadInput, |
| 25 | 26 | type DeleteThreadInput, |
| 26 | 27 | type EditQueuedRunInput, |
| ... | ... | @@ -65,6 +66,7 @@ import { |
| 65 | 66 | promoteQueuedRun, |
| 66 | 67 | reorderQueuedRun, |
| 67 | 68 | resumeThreadQueue, |
| 69 | retryWorkspacePreparation, | |
| 68 | 70 | linkThreadPullRequest, |
| 69 | 71 | respondToThreadApproval, |
| 70 | 72 | respondToThreadUserInput, |
| ... | ... | @@ -357,6 +359,12 @@ export function createThreadEnvironmentAtoms<R, E>( |
| 357 | 359 | scheduler, |
| 358 | 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 | 368 | editQueuedRun: createEnvironmentCommand(runtime, { |
| 361 | 369 | label: "environment-data:commands:thread:edit-queued-run", |
| 362 | 370 | execute: (input: EditQueuedRunInput) => editQueuedRun(input), |
packages/client-runtime/src/state/turnItemPresentation.test.ts+70-2| ... | ... | @@ -1,8 +1,18 @@ |
| 1 | import { RunId, ThreadId, TurnItemId, type OrchestrationV2TurnItem } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, | |
| 3 | RunId, | |
| 4 | ThreadId, | |
| 5 | TurnItemId, | |
| 6 | type OrchestrationV2Run, | |
| 7 | type OrchestrationV2TurnItem, | |
| 8 | } from "@t3tools/contracts"; | |
| 2 | 9 | import * as DateTime from "effect/DateTime"; |
| 3 | 10 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 11 | |
| 5 | import { turnItemIsWorkspacePreparation } from "./turnItemPresentation.ts"; | |
| 12 | import { | |
| 13 | turnItemIsWorkspacePreparation, | |
| 14 | workspacePreparationRetryRunIds, | |
| 15 | } from "./turnItemPresentation.ts"; | |
| 6 | 16 | |
| 7 | 17 | function command(input: string): OrchestrationV2TurnItem { |
| 8 | 18 | const now = DateTime.makeUnsafe("2026-08-03T00:00:00.000Z"); |
| ... | ... | @@ -28,9 +38,67 @@ function command(input: string): OrchestrationV2TurnItem { |
| 28 | 38 | }; |
| 29 | 39 | } |
| 30 | 40 | |
| 41 | function 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 | ||
| 61 | const worktree = { type: "worktree", baseRef: "main" } as const; | |
| 62 | function 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 | ||
| 31 | 73 | describe("turnItemIsWorkspacePreparation", () => { |
| 32 | 74 | it("identifies the synthetic workspace preparation command", () => { |
| 33 | 75 | expect(turnItemIsWorkspacePreparation(command("Preparing workspace"))).toBe(true); |
| 34 | 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 | ||
| 86 | describe("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 | import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, | |
| 3 | type OrchestrationV2Run, | |
| 4 | type OrchestrationV2TurnItem, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | |
| 3 | 7 | const WORKSPACE_PREPARATION_INPUT = "Preparing workspace"; |
| 4 | 8 | |
| 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 | */ | |
| 6 | 13 | export 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 | */ | |
| 27 | export 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 | 508 | export type OrchestrationV2RunBackgroundWorkCancelled = |
| 509 | 509 | typeof OrchestrationV2RunBackgroundWorkCancelled.Type; |
| 510 | 510 | |
| 511 | export 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 | ]); | |
| 528 | export type OrchestrationV2ThreadLaunchWorkspaceStrategy = | |
| 529 | typeof OrchestrationV2ThreadLaunchWorkspaceStrategy.Type; | |
| 530 | ||
| 531 | /** Failure code on the error item a failed workspace preparation leaves. */ | |
| 532 | export const ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE = "workspace_preparation_failed"; | |
| 533 | ||
| 511 | 534 | export const OrchestrationV2Run = Schema.Struct({ |
| 512 | 535 | id: RunId, |
| 513 | 536 | threadId: ThreadId, |
| ... | ... | @@ -549,6 +572,8 @@ export const OrchestrationV2Run = Schema.Struct({ |
| 549 | 572 | }), |
| 550 | 573 | ), |
| 551 | 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 | }); |
| 553 | 578 | export type OrchestrationV2Run = typeof OrchestrationV2Run.Type; |
| 554 | 579 | |
| ... | ... | @@ -2691,7 +2716,10 @@ export const OrchestrationV2Command = Schema.Union([ |
| 2691 | 2716 | }), |
| 2692 | 2717 | ), |
| 2693 | 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 | 2723 | Schema.Struct({ type: Schema.Literal("steer_active"), targetRunId: RunId }), |
| 2696 | 2724 | Schema.Struct({ type: Schema.Literal("restart_active"), targetRunId: RunId }), |
| 2697 | 2725 | Schema.Struct({ type: Schema.Literal("queue_after_active") }), |
| ... | ... | @@ -2725,6 +2753,13 @@ export const OrchestrationV2Command = Schema.Union([ |
| 2725 | 2753 | runId: RunId, |
| 2726 | 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 | 2763 | Schema.Struct({ |
| 2729 | 2764 | type: Schema.Literal("run.interrupt"), |
| 2730 | 2765 | commandId: CommandId, |
| ... | ... | @@ -2961,26 +2996,6 @@ export const OrchestrationV2ArchivedShellStreamItem = Schema.Union([ |
| 2961 | 2996 | export type OrchestrationV2ArchivedShellStreamItem = |
| 2962 | 2997 | typeof OrchestrationV2ArchivedShellStreamItem.Type; |
| 2963 | 2998 | |
| 2964 | export 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 | ]); | |
| 2981 | export type OrchestrationV2ThreadLaunchWorkspaceStrategy = | |
| 2982 | typeof OrchestrationV2ThreadLaunchWorkspaceStrategy.Type; | |
| 2983 | ||
| 2984 | 2999 | export const OrchestrationV2ThreadLaunchInput = Schema.Struct({ |
| 2985 | 3000 | commandId: CommandId, |
| 2986 | 3001 | creationSource: Schema.optional(OrchestrationV2CreationSource), |