| author | |
| committer | |
| log | 0310cbf9f46ca94e9df0231a3440bca08ac44709 |
| tree | cdbad762a5725026a9d34615f51ec1fed94d0281 |
| parent | 5623089aea68ca62811f51321686c259fa4c810f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>4 files changed, 166 insertions(+), 20 deletions(-)
apps/server/src/server.test.ts+42-9| ... | ... | @@ -10836,17 +10836,30 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 10836 | 10836 | ), |
| 10837 | 10837 | ); |
| 10838 | 10838 | |
| 10839 | assert.equal(response.sequence, 5); | |
| 10839 | assert.equal(response.sequence, 6); | |
| 10840 | 10840 | assert.deepEqual( |
| 10841 | 10841 | dispatchedCommands.map((command) => command.type), |
| 10842 | 10842 | [ |
| 10843 | 10843 | "thread.create", |
| 10844 | "thread.session.set", | |
| 10844 | 10845 | "thread.meta.update", |
| 10845 | 10846 | "thread.activity.append", |
| 10846 | 10847 | "thread.activity.append", |
| 10847 | 10848 | "thread.turn.start", |
| 10848 | 10849 | ], |
| 10849 | 10850 | ); |
| 10851 | // The checkout can take minutes, so the thread reads as working from | |
| 10852 | // the moment setup starts rather than only once the turn is dispatched. | |
| 10853 | const preparingCommand = dispatchedCommands[1]; | |
| 10854 | assertTrue(preparingCommand?.type === "thread.session.set"); | |
| 10855 | if (preparingCommand?.type === "thread.session.set") { | |
| 10856 | assert.equal(preparingCommand.session.status, "starting"); | |
| 10857 | assert.equal(preparingCommand.session.activeTurnId, null); | |
| 10858 | assert.equal( | |
| 10859 | preparingCommand.session.providerInstanceId, | |
| 10860 | defaultModelSelection.instanceId, | |
| 10861 | ); | |
| 10862 | } | |
| 10850 | 10863 | assert.deepEqual(createWorktree.mock.calls[0]?.[0], { |
| 10851 | 10864 | cwd: "/tmp/project", |
| 10852 | 10865 | refName: fetchedOriginCommit, |
| ... | ... | @@ -10902,7 +10915,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 10902 | 10915 | setupActivities.map((command) => command.activity.kind), |
| 10903 | 10916 | ["setup-script.requested", "setup-script.started"], |
| 10904 | 10917 | ); |
| 10905 | const finalCommand = dispatchedCommands[4]; | |
| 10918 | const finalCommand = dispatchedCommands[5]; | |
| 10906 | 10919 | assertTrue(finalCommand?.type === "thread.turn.start"); |
| 10907 | 10920 | if (finalCommand?.type === "thread.turn.start") { |
| 10908 | 10921 | assert.equal(finalCommand.bootstrap, undefined); |
| ... | ... | @@ -11284,10 +11297,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 11284 | 11297 | ), |
| 11285 | 11298 | ); |
| 11286 | 11299 | |
| 11287 | assert.equal(response.sequence, 4); | |
| 11300 | assert.equal(response.sequence, 5); | |
| 11288 | 11301 | assert.deepEqual( |
| 11289 | 11302 | dispatchedCommands.map((command) => command.type), |
| 11290 | ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], | |
| 11303 | [ | |
| 11304 | "thread.create", | |
| 11305 | "thread.session.set", | |
| 11306 | "thread.meta.update", | |
| 11307 | "thread.activity.append", | |
| 11308 | "thread.turn.start", | |
| 11309 | ], | |
| 11291 | 11310 | ); |
| 11292 | 11311 | const setupFailureActivity = dispatchedCommands.find( |
| 11293 | 11312 | (command): command is Extract<OrchestrationCommand, { type: "thread.activity.append" }> => |
| ... | ... | @@ -11411,10 +11430,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 11411 | 11430 | ), |
| 11412 | 11431 | ); |
| 11413 | 11432 | |
| 11414 | assert.equal(response.sequence, 4); | |
| 11433 | assert.equal(response.sequence, 5); | |
| 11415 | 11434 | assert.deepEqual( |
| 11416 | 11435 | dispatchedCommands.map((command) => command.type), |
| 11417 | ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], | |
| 11436 | [ | |
| 11437 | "thread.create", | |
| 11438 | "thread.session.set", | |
| 11439 | "thread.meta.update", | |
| 11440 | "thread.activity.append", | |
| 11441 | "thread.turn.start", | |
| 11442 | ], | |
| 11418 | 11443 | ); |
| 11419 | 11444 | const setupActivities = dispatchedCommands.filter( |
| 11420 | 11445 | (command): command is Extract<OrchestrationCommand, { type: "thread.activity.append" }> => |
| ... | ... | @@ -11676,7 +11701,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 11676 | 11701 | assert.strictEqual(result.failure.bootstrapThreadDisposition, "deleted"); |
| 11677 | 11702 | assert.deepEqual( |
| 11678 | 11703 | dispatchedCommands.map((command) => command.type), |
| 11679 | ["thread.create", "thread.delete"], | |
| 11704 | ["thread.create", "thread.session.set", "thread.delete"], | |
| 11680 | 11705 | ); |
| 11681 | 11706 | assert.isDefined(pendingAttachmentId); |
| 11682 | 11707 | assert.isTrue( |
| ... | ... | @@ -11874,8 +11899,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 11874 | 11899 | assert.strictEqual(result.failure.bootstrapThreadDisposition, undefined); |
| 11875 | 11900 | assert.deepEqual( |
| 11876 | 11901 | dispatchedCommands.map((command) => command.type), |
| 11877 | ["thread.create", "thread.delete"], | |
| 11878 | ); | |
| 11902 | ["thread.create", "thread.session.set", "thread.delete", "thread.session.set"], | |
| 11903 | ); | |
| 11904 | // The surviving thread must not keep its preparing session, or it would | |
| 11905 | // read as working forever. | |
| 11906 | const failedSession = dispatchedCommands[3]; | |
| 11907 | assertTrue(failedSession?.type === "thread.session.set"); | |
| 11908 | if (failedSession?.type === "thread.session.set") { | |
| 11909 | assert.equal(failedSession.session.status, "error"); | |
| 11910 | assert.include(failedSession.session.lastError ?? "", "worktree exploded"); | |
| 11911 | } | |
| 11879 | 11912 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 11880 | 11913 | ); |
| 11881 | 11914 |
apps/server/src/ws.ts+63-1| ... | ... | @@ -1017,6 +1017,30 @@ const makeWsRpcLayer = ( |
| 1017 | 1017 | // one so terminals the user opened meanwhile survive. |
| 1018 | 1018 | let setupTerminalId: string | null = null; |
| 1019 | 1019 | |
| 1020 | // Set once the checkout starts; see the session.set below. | |
| 1021 | let preparingSessionSet = false; | |
| 1022 | const markPreparingSessionFailed = (detail: string) => | |
| 1023 | Effect.gen(function* () { | |
| 1024 | const failedAt = yield* nowIso; | |
| 1025 | yield* dispatchFromClient({ | |
| 1026 | type: "thread.session.set", | |
| 1027 | commandId: yield* serverCommandId("bootstrap-thread-preparing-failed"), | |
| 1028 | threadId, | |
| 1029 | session: { | |
| 1030 | threadId, | |
| 1031 | status: "error", | |
| 1032 | providerName: null, | |
| 1033 | providerInstanceId: | |
| 1034 | bootstrap?.createThread?.modelSelection.instanceId ?? | |
| 1035 | command.modelSelection?.instanceId, | |
| 1036 | runtimeMode: command.runtimeMode, | |
| 1037 | activeTurnId: null, | |
| 1038 | lastError: detail.trim().length > 0 ? detail : "Worktree setup failed.", | |
| 1039 | updatedAt: failedAt, | |
| 1040 | }, | |
| 1041 | createdAt: failedAt, | |
| 1042 | }); | |
| 1043 | }); | |
| 1020 | 1044 | const cleanupCreatedThread = () => |
| 1021 | 1045 | createdThread |
| 1022 | 1046 | ? serverCommandId("bootstrap-thread-delete").pipe( |
| ... | ... | @@ -1340,6 +1364,32 @@ const makeWsRpcLayer = ( |
| 1340 | 1364 | } |
| 1341 | 1365 | |
| 1342 | 1366 | if (prepareWorktree && shouldPrepareWorktree && worktreeBaseRef) { |
| 1367 | if (bootstrap?.createThread && createdThread) { | |
| 1368 | // The checkout and setup script can run for minutes before the | |
| 1369 | // turn starts, and the created thread carries no message or | |
| 1370 | // turn until then. Project a starting session now so every | |
| 1371 | // client lists the thread as working and a reopened thread | |
| 1372 | // knows to follow the setup stream. A failed or cancelled setup | |
| 1373 | // deletes the thread, so nothing lingers. | |
| 1374 | const preparingAt = yield* nowIso; | |
| 1375 | yield* dispatchFromClient({ | |
| 1376 | type: "thread.session.set", | |
| 1377 | commandId: yield* serverCommandId("bootstrap-thread-preparing"), | |
| 1378 | threadId, | |
| 1379 | session: { | |
| 1380 | threadId, | |
| 1381 | status: "starting", | |
| 1382 | providerName: null, | |
| 1383 | providerInstanceId: bootstrap.createThread.modelSelection.instanceId, | |
| 1384 | runtimeMode: command.runtimeMode, | |
| 1385 | activeTurnId: null, | |
| 1386 | lastError: null, | |
| 1387 | updatedAt: preparingAt, | |
| 1388 | }, | |
| 1389 | createdAt: preparingAt, | |
| 1390 | }); | |
| 1391 | preparingSessionSet = true; | |
| 1392 | } | |
| 1343 | 1393 | yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running"); |
| 1344 | 1394 | let checkoutTotal: number | null = null; |
| 1345 | 1395 | const worktree = yield* gitWorkflow.createWorktree( |
| ... | ... | @@ -1483,7 +1533,19 @@ const makeWsRpcLayer = ( |
| 1483 | 1533 | Effect.logWarning("bootstrap thread cleanup failed", { |
| 1484 | 1534 | threadId, |
| 1485 | 1535 | detail: Cause.pretty(cleanupCause), |
| 1486 | }).pipe(Effect.flatMap(() => Effect.fail(dispatchError))), | |
| 1536 | }).pipe( | |
| 1537 | // The thread outlived its setup. Its preparing session | |
| 1538 | // must not read as working forever, so record the failure | |
| 1539 | // on it instead. | |
| 1540 | Effect.andThen( | |
| 1541 | preparingSessionSet | |
| 1542 | ? markPreparingSessionFailed(dispatchError.message).pipe( | |
| 1543 | Effect.ignoreCause({ log: true }), | |
| 1544 | ) | |
| 1545 | : Effect.void, | |
| 1546 | ), | |
| 1547 | Effect.flatMap(() => Effect.fail(dispatchError)), | |
| 1548 | ), | |
| 1487 | 1549 | onSuccess: (threadDeleted) => |
| 1488 | 1550 | Effect.fail( |
| 1489 | 1551 | threadDeleted |
apps/web/src/components/ChatView.tsx+60-9| ... | ... | @@ -349,7 +349,7 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; |
| 349 | 349 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| 350 | 350 | import { MessagesTimeline } from "./chat/MessagesTimeline"; |
| 351 | 351 | import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; |
| 352 | import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; | |
| 352 | import { resolveTimelineIsAtEnd, worktreeSetupAgentStarted } from "./chat/MessagesTimeline.logic"; | |
| 353 | 353 | import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout"; |
| 354 | 354 | import { ChatHeader } from "./chat/ChatHeader"; |
| 355 | 355 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; |
| ... | ... | @@ -3493,14 +3493,47 @@ export default function ChatView(props: ChatViewProps) { |
| 3493 | 3493 | const worktreeSetupOwnerKey = draftId ?? routeThreadKey; |
| 3494 | 3494 | const worktreeSetupActive = |
| 3495 | 3495 | worktreeSetupRef !== null && worktreeSetupRef.ownerKey === worktreeSetupOwnerKey; |
| 3496 | // A thread reopened mid-setup has no dispatch ref: this view remounted. | |
| 3497 | // The server projects a starting session before any message or turn exists | |
| 3498 | // for exactly that window, so follow the setup stream from the thread's own | |
| 3499 | // state. The ref is adopted below so the card then follows the same | |
| 3500 | // lifecycle as the original send, including an async setup script that | |
| 3501 | // keeps running after the agent's turn lands. | |
| 3502 | const resumedWorktreeSetupRef = | |
| 3503 | !worktreeSetupActive && | |
| 3504 | isServerThread && | |
| 3505 | activeThreadRef !== null && | |
| 3506 | activeThreadShell?.session?.status === "starting" && | |
| 3507 | activeThreadShell.latestTurn === null && | |
| 3508 | activeThreadShell.latestUserMessageAt === null | |
| 3509 | ? activeThreadRef | |
| 3510 | : null; | |
| 3511 | useEffect(() => { | |
| 3512 | if (!resumedWorktreeSetupRef) return; | |
| 3513 | // Only a ref owned by this route survives adoption. This component is | |
| 3514 | // reused across routes, so a ref left by another thread's send is stale | |
| 3515 | // here and would leave the resumed setup with no target after handoff. | |
| 3516 | setWorktreeSetupRef((current) => | |
| 3517 | current?.ownerKey === worktreeSetupOwnerKey | |
| 3518 | ? current | |
| 3519 | : { ...resumedWorktreeSetupRef, ownerKey: worktreeSetupOwnerKey }, | |
| 3520 | ); | |
| 3521 | }, [resumedWorktreeSetupRef, worktreeSetupOwnerKey]); | |
| 3496 | 3522 | // The setup runs on the environment that received the dispatch, so both |
| 3497 | 3523 | // the subscription and cancel target that one even if the draft's machine |
| 3498 | 3524 | // picker changes underneath. |
| 3525 | const worktreeSetupTarget = useMemo( | |
| 3526 | () => | |
| 3527 | worktreeSetupActive | |
| 3528 | ? { environmentId: worktreeSetupRef.environmentId, threadId: worktreeSetupRef.threadId } | |
| 3529 | : resumedWorktreeSetupRef, | |
| 3530 | [resumedWorktreeSetupRef, worktreeSetupActive, worktreeSetupRef], | |
| 3531 | ); | |
| 3499 | 3532 | const worktreeSetupQuery = useEnvironmentQuery( |
| 3500 | worktreeSetupActive | |
| 3533 | worktreeSetupTarget | |
| 3501 | 3534 | ? vcsEnvironment.worktreeSetup({ |
| 3502 | environmentId: worktreeSetupRef.environmentId, | |
| 3503 | input: { threadId: worktreeSetupRef.threadId }, | |
| 3535 | environmentId: worktreeSetupTarget.environmentId, | |
| 3536 | input: { threadId: worktreeSetupTarget.threadId }, | |
| 3504 | 3537 | }) |
| 3505 | 3538 | : null, |
| 3506 | 3539 | ); |
| ... | ... | @@ -3511,7 +3544,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3511 | 3544 | if (latestWorktreeSetup) setHeldWorktreeSetup(latestWorktreeSetup); |
| 3512 | 3545 | }, [latestWorktreeSetup]); |
| 3513 | 3546 | const worktreeSetup = |
| 3514 | worktreeSetupActive && heldWorktreeSetup?.threadId === worktreeSetupRef.threadId | |
| 3547 | worktreeSetupTarget !== null && heldWorktreeSetup?.threadId === worktreeSetupTarget.threadId | |
| 3515 | 3548 | ? heldWorktreeSetup |
| 3516 | 3549 | : null; |
| 3517 | 3550 | // A finished card is dropped once the agent's turn shows in the timeline: |
| ... | ... | @@ -3537,16 +3570,32 @@ export default function ChatView(props: ChatViewProps) { |
| 3537 | 3570 | useEffect(() => { |
| 3538 | 3571 | if (worktreeSetupSettledKey) pendingWorktreeSetupByThreadKey.delete(worktreeSetupSettledKey); |
| 3539 | 3572 | }, [worktreeSetupSettledKey]); |
| 3573 | // Sends wait for the agent handoff, not for the setup script: an async | |
| 3574 | // script keeps the snapshot running while the agent already works, and a | |
| 3575 | // follow-up must not be held behind a slow install. A resumed setup has no | |
| 3576 | // snapshot until its first stream event, so that gap blocks as well. The | |
| 3577 | // gap is judged by the shell, not by the resumed ref, since adopting the | |
| 3578 | // ref clears it before the query has delivered anything. | |
| 3579 | const worktreeSetupAwaitingFirstSnapshot = | |
| 3580 | worktreeSetup === null && | |
| 3581 | worktreeSetupTarget !== null && | |
| 3582 | isServerThread && | |
| 3583 | activeThreadShell?.session?.status === "starting" && | |
| 3584 | activeThreadShell.latestTurn === null; | |
| 3585 | const worktreeSetupBlocksSend = | |
| 3586 | worktreeSetup !== null | |
| 3587 | ? worktreeSetup.phase === "running" && !worktreeSetupAgentStarted(worktreeSetup) | |
| 3588 | : worktreeSetupAwaitingFirstSnapshot; | |
| 3540 | 3589 | const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { |
| 3541 | 3590 | reportFailure: false, |
| 3542 | 3591 | }); |
| 3543 | 3592 | const onCancelWorktreeSetup = useCallback(() => { |
| 3544 | if (!worktreeSetup || !worktreeSetupRef || worktreeSetup.phase !== "running") return; | |
| 3593 | if (!worktreeSetup || !worktreeSetupTarget || worktreeSetup.phase !== "running") return; | |
| 3545 | 3594 | void cancelWorktreeSetup({ |
| 3546 | environmentId: worktreeSetupRef.environmentId, | |
| 3595 | environmentId: worktreeSetupTarget.environmentId, | |
| 3547 | 3596 | input: { threadId: worktreeSetup.threadId }, |
| 3548 | 3597 | }); |
| 3549 | }, [cancelWorktreeSetup, worktreeSetup, worktreeSetupRef]); | |
| 3598 | }, [cancelWorktreeSetup, worktreeSetup, worktreeSetupTarget]); | |
| 3550 | 3599 | // The setup terminal belongs to the thread that was set up. A failed |
| 3551 | 3600 | // bootstrap deletes that thread and closes its terminals, so only offer the |
| 3552 | 3601 | // terminal while the setup thread is still the active one. |
| ... | ... | @@ -9313,7 +9362,9 @@ export default function ChatView(props: ChatViewProps) { |
| 9313 | 9362 | ? "Sending feedback" |
| 9314 | 9363 | : threadDetailLoading |
| 9315 | 9364 | ? "Messages loading" |
| 9316 | : projectCloneSendBlockReason | |
| 9365 | : worktreeSetupBlocksSend | |
| 9366 | ? "Preparing worktree" | |
| 9367 | : projectCloneSendBlockReason | |
| 9317 | 9368 | } |
| 9318 | 9369 | isPreparingWorktree={isPreparingWorktree} |
| 9319 | 9370 | bannerItems={composerBannerItems} |
apps/web/src/components/chat/MessagesTimeline.logic.ts+1-1| ... | ... | @@ -1336,7 +1336,7 @@ export function deriveMessagesTimelineRows(input: { |
| 1336 | 1336 | export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row"; |
| 1337 | 1337 | |
| 1338 | 1338 | /** True once the bootstrap handed off to the agent (async setup script may still run). */ |
| 1339 | function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean { | |
| 1339 | export function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean { | |
| 1340 | 1340 | return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done"); |
| 1341 | 1341 | } |
| 1342 | 1342 |