| author | |
| committer | |
| log | b4620d595554654c259ec7c0afb3d3d5532959a8 |
| tree | 9f414faad9c31fcd853040d3f1e89aca65addfed |
| parent | b17cc2ab5d5029f898121798ca19db3288cba616 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>4 files changed, 186 insertions(+), 20 deletions(-)
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+103-9| ... | ... | @@ -297,6 +297,7 @@ describe("CheckpointReactor", () => { |
| 297 | 297 | readonly threadWorktreePath?: string | null; |
| 298 | 298 | readonly threadBranch?: string | null; |
| 299 | 299 | readonly secondThreadSharingWorktree?: boolean; |
| 300 | readonly secondThreadWorktreePath?: (cwd: string) => string; | |
| 300 | 301 | readonly localStatusRefName?: string | null; |
| 301 | 302 | readonly providerSessionCwd?: string; |
| 302 | 303 | readonly providerName?: ProviderDriverKind; |
| ... | ... | @@ -436,7 +437,8 @@ describe("CheckpointReactor", () => { |
| 436 | 437 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 437 | 438 | runtimeMode: "approval-required", |
| 438 | 439 | branch: options?.threadBranch ?? null, |
| 439 | worktreePath: options?.threadWorktreePath ?? cwd, | |
| 440 | worktreePath: | |
| 441 | options?.threadWorktreePath !== undefined ? options.threadWorktreePath : cwd, | |
| 440 | 442 | createdAt, |
| 441 | 443 | }) |
| 442 | 444 | .pipe( |
| ... | ... | @@ -455,7 +457,8 @@ describe("CheckpointReactor", () => { |
| 455 | 457 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 456 | 458 | runtimeMode: "approval-required", |
| 457 | 459 | branch: null, |
| 458 | worktreePath: options?.threadWorktreePath ?? cwd, | |
| 460 | worktreePath: | |
| 461 | options?.secondThreadWorktreePath?.(cwd) ?? options?.threadWorktreePath ?? cwd, | |
| 459 | 462 | createdAt, |
| 460 | 463 | }), |
| 461 | 464 | ) |
| ... | ... | @@ -497,6 +500,81 @@ describe("CheckpointReactor", () => { |
| 497 | 500 | }; |
| 498 | 501 | } |
| 499 | 502 | |
| 503 | effectIt.effect.each([ | |
| 504 | "active", | |
| 505 | "archived", | |
| 506 | "alias", | |
| 507 | "nested", | |
| 508 | "ancestor", | |
| 509 | "project-root", | |
| 510 | "conversation", | |
| 511 | ] as const)("preserves sibling files when reverting a shared workspace, owner=%s", (owner) => | |
| 512 | Effect.gen(function* () { | |
| 513 | const harness = yield* Effect.promise(() => | |
| 514 | createHarness({ | |
| 515 | secondThreadSharingWorktree: true, | |
| 516 | ...(owner === "alias" || owner === "nested" || owner === "ancestor" | |
| 517 | ? { | |
| 518 | secondThreadWorktreePath: (cwd: string) => { | |
| 519 | if (owner === "ancestor") return NodePath.dirname(cwd); | |
| 520 | if (owner === "nested") { | |
| 521 | const nested = NodePath.join(cwd, "nested-owner"); | |
| 522 | NodeFS.mkdirSync(nested); | |
| 523 | return nested; | |
| 524 | } | |
| 525 | const alias = `${cwd}-alias`; | |
| 526 | NodeFS.symlinkSync(cwd, alias, "junction"); | |
| 527 | tempDirs.push(alias); | |
| 528 | return alias; | |
| 529 | }, | |
| 530 | } | |
| 531 | : {}), | |
| 532 | }), | |
| 533 | ); | |
| 534 | const createdAt = "2026-01-01T00:00:02.000Z"; | |
| 535 | if (owner === "archived") | |
| 536 | yield* harness.engine.dispatch({ | |
| 537 | type: "thread.archive", | |
| 538 | commandId: CommandId.make("cmd-archive-owner"), | |
| 539 | threadId: ThreadId.make("thread-2"), | |
| 540 | }); | |
| 541 | if (owner === "project-root") | |
| 542 | yield* harness.engine.dispatch({ | |
| 543 | type: "thread.meta.update", | |
| 544 | commandId: CommandId.make("cmd-root-owner"), | |
| 545 | threadId: ThreadId.make("thread-2"), | |
| 546 | worktreePath: null, | |
| 547 | }); | |
| 548 | const siblingFile = NodePath.join( | |
| 549 | harness.cwd, | |
| 550 | ...(owner === "nested" ? ["nested-owner"] : []), | |
| 551 | "sibling-work.txt", | |
| 552 | ); | |
| 553 | NodeFS.writeFileSync(siblingFile, "sibling work\n"); | |
| 554 | yield* harness.engine.dispatch({ | |
| 555 | type: owner === "conversation" ? "thread.conversation.revert" : "thread.checkpoint.revert", | |
| 556 | commandId: CommandId.make("cmd-shared-revert"), | |
| 557 | threadId: ThreadId.make("thread-1"), | |
| 558 | turnCount: 0, | |
| 559 | createdAt, | |
| 560 | }); | |
| 561 | yield* Effect.promise(harness.drain); | |
| 562 | expect(NodeFS.readFileSync(siblingFile, "utf8")).toBe("sibling work\n"); | |
| 563 | expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v3\n"); | |
| 564 | const model = yield* Effect.promise(harness.readModel); | |
| 565 | const failure = model.threads | |
| 566 | .find((t) => t.id === "thread-1") | |
| 567 | ?.activities.find((a) => a.kind === "checkpoint.revert.failed"); | |
| 568 | if (owner === "conversation") expect(failure).toBeUndefined(); | |
| 569 | else { | |
| 570 | expect(failure?.payload).toMatchObject({ | |
| 571 | detail: expect.stringContaining("isolated worktree"), | |
| 572 | }); | |
| 573 | expect(harness.provider.rollbackConversation).not.toHaveBeenCalled(); | |
| 574 | } | |
| 575 | }), | |
| 576 | ); | |
| 577 | ||
| 500 | 578 | effectIt.effect("captures baseline and large turn summaries before completion receipts", () => |
| 501 | 579 | Effect.gen(function* () { |
| 502 | 580 | const harness = yield* Effect.promise(() => |
| ... | ... | @@ -1963,7 +2041,7 @@ describe("CheckpointReactor", () => { |
| 1963 | 2041 | }); |
| 1964 | 2042 | |
| 1965 | 2043 | it.each([false, true])( |
| 1966 | "reverts without an active session using project cwd fallback: %s", | |
| 2044 | "restores files only in an isolated worktree without an active session, project cwd=%s", | |
| 1967 | 2045 | async (useProjectCwd) => { |
| 1968 | 2046 | const harness = await createHarness({ |
| 1969 | 2047 | hasSession: false, |
| ... | ... | @@ -1995,12 +2073,28 @@ describe("CheckpointReactor", () => { |
| 1995 | 2073 | }), |
| 1996 | 2074 | ); |
| 1997 | 2075 | |
| 1998 | await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); | |
| 1999 | expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ | |
| 2000 | threadId: ThreadId.make("thread-1"), | |
| 2001 | numTurns: 1, | |
| 2002 | }); | |
| 2003 | expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v1\n"); | |
| 2076 | await harness.drain(); | |
| 2077 | if (useProjectCwd) { | |
| 2078 | expect(harness.provider.rollbackConversation).not.toHaveBeenCalled(); | |
| 2079 | expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v3\n"); | |
| 2080 | const model = await harness.readModel(); | |
| 2081 | expect(model.threads[0]?.activities).toEqual( | |
| 2082 | expect.arrayContaining([ | |
| 2083 | expect.objectContaining({ | |
| 2084 | kind: "checkpoint.revert.failed", | |
| 2085 | payload: expect.objectContaining({ | |
| 2086 | detail: expect.stringContaining("isolated worktree"), | |
| 2087 | }), | |
| 2088 | }), | |
| 2089 | ]), | |
| 2090 | ); | |
| 2091 | } else { | |
| 2092 | expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ | |
| 2093 | threadId: ThreadId.make("thread-1"), | |
| 2094 | numTurns: 1, | |
| 2095 | }); | |
| 2096 | expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v1\n"); | |
| 2097 | } | |
| 2004 | 2098 | }, |
| 2005 | 2099 | ); |
| 2006 | 2100 | }); |
apps/server/src/orchestration/Layers/CheckpointReactor.ts+64| ... | ... | @@ -15,6 +15,8 @@ import * as Crypto from "effect/Crypto"; |
| 15 | 15 | import * as DateTime from "effect/DateTime"; |
| 16 | 16 | import * as Effect from "effect/Effect"; |
| 17 | 17 | import * as Layer from "effect/Layer"; |
| 18 | import * as FileSystem from "effect/FileSystem"; | |
| 19 | import * as Path from "effect/Path"; | |
| 18 | 20 | import * as Option from "effect/Option"; |
| 19 | 21 | import type * as PlatformError from "effect/PlatformError"; |
| 20 | 22 | import * as Stream from "effect/Stream"; |
| ... | ... | @@ -87,6 +89,8 @@ const make = Effect.gen(function* () { |
| 87 | 89 | const checkpointStore = yield* CheckpointStore.CheckpointStore; |
| 88 | 90 | const receiptBus = yield* RuntimeReceiptBus; |
| 89 | 91 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; |
| 92 | const fileSystem = yield* FileSystem.FileSystem; | |
| 93 | const path = yield* Path.Path; | |
| 90 | 94 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 91 | 95 | const pullRequests = yield* PullRequestService.PullRequestService; |
| 92 | 96 | const startedTurns = new Map<ThreadId, TurnId>(); |
| ... | ... | @@ -687,6 +691,55 @@ const make = Effect.gen(function* () { |
| 687 | 691 | }); |
| 688 | 692 | }); |
| 689 | 693 | |
| 694 | // Checkpoints contain the whole checkout, so restoring a shared cwd can erase a sibling's work. | |
| 695 | const isRestoreWorkspaceIsolated = Effect.fn("isRestoreWorkspaceIsolated")(function* ( | |
| 696 | thread: { readonly id: ThreadId; readonly worktreePath: string | null }, | |
| 697 | cwd: string, | |
| 698 | ) { | |
| 699 | if (thread.worktreePath === null) return false; | |
| 700 | const canonicalCwd = yield* fileSystem.realPath(cwd); | |
| 701 | if ((yield* fileSystem.realPath(thread.worktreePath)) !== canonicalCwd) return false; | |
| 702 | const active = yield* projectionSnapshotQuery.getShellSnapshot(); | |
| 703 | const archived = yield* projectionSnapshotQuery.getArchivedShellSnapshot(); | |
| 704 | const projects = [...active.projects, ...archived.projects]; | |
| 705 | const paths = new Set<string>(); | |
| 706 | for (const other of [...active.threads, ...archived.threads]) { | |
| 707 | if (other.id === thread.id) continue; | |
| 708 | const candidate = | |
| 709 | other.worktreePath ?? | |
| 710 | projects.find((project) => project.id === other.projectId)?.workspaceRoot; | |
| 711 | if (candidate !== undefined) paths.add(candidate); | |
| 712 | } | |
| 713 | for (const session of yield* providerService.listSessions()) { | |
| 714 | if ( | |
| 715 | session.threadId !== thread.id && | |
| 716 | session.status !== "closed" && | |
| 717 | session.cwd !== undefined | |
| 718 | ) | |
| 719 | paths.add(session.cwd); | |
| 720 | } | |
| 721 | for (const candidate of paths) { | |
| 722 | const otherCwd = yield* fileSystem | |
| 723 | .realPath(candidate) | |
| 724 | .pipe( | |
| 725 | Effect.catch((error) => | |
| 726 | error.reason._tag === "NotFound" ? Effect.succeed(null) : Effect.fail(error), | |
| 727 | ), | |
| 728 | ); | |
| 729 | if (otherCwd === null) continue; | |
| 730 | const isWithin = (parent: string, child: string) => { | |
| 731 | const relative = path.relative(parent, child); | |
| 732 | return ( | |
| 733 | relative === "" || | |
| 734 | (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`)) | |
| 735 | ); | |
| 736 | }; | |
| 737 | // Parent and nested owners can both have files inside the restore target. | |
| 738 | if (isWithin(canonicalCwd, otherCwd) || isWithin(otherCwd, canonicalCwd)) return false; | |
| 739 | } | |
| 740 | return true; | |
| 741 | }); | |
| 742 | ||
| 690 | 743 | const handleRevertRequested = Effect.fn("handleRevertRequested")(function* ( |
| 691 | 744 | event: Extract<OrchestrationEvent, { type: "thread.checkpoint-revert-requested" }>, |
| 692 | 745 | ) { |
| ... | ... | @@ -742,6 +795,17 @@ const make = Effect.gen(function* () { |
| 742 | 795 | return; |
| 743 | 796 | } |
| 744 | 797 | |
| 798 | if (!(yield* isRestoreWorkspaceIsolated(thread, checkpointCwd))) { | |
| 799 | yield* appendRevertFailureActivity({ | |
| 800 | threadId: thread.id, | |
| 801 | turnCount: event.payload.turnCount, | |
| 802 | detail: | |
| 803 | "File restore requires an isolated worktree. This workspace may contain changes from another thread. Rewind the conversation without restoring files instead.", | |
| 804 | createdAt: now, | |
| 805 | }).pipe(Effect.catch(() => Effect.void)); | |
| 806 | return; | |
| 807 | } | |
| 808 | ||
| 745 | 809 | const targetCheckpointRef = |
| 746 | 810 | event.payload.turnCount === 0 |
| 747 | 811 | ? checkpointRefForThreadTurn(event.payload.threadId, 0) |
apps/web/src/components/ChatView.tsx+15-10| ... | ... | @@ -10393,20 +10393,25 @@ export default function ChatView(props: ChatViewProps) { |
| 10393 | 10393 | <AlertDialogDescription> |
| 10394 | 10394 | Rewind chat to before this message. Your prompt and attachments return to the |
| 10395 | 10395 | composer. |
| 10396 | {activeWorktreePath === null | |
| 10397 | ? " Files stay as they are because this thread shares the project directory." | |
| 10398 | : null} | |
| 10396 | 10399 | </AlertDialogDescription> |
| 10397 | 10400 | </AlertDialogHeader> |
| 10398 | 10401 | <AlertDialogFooter> |
| 10399 | 10402 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> |
| 10400 | <Button | |
| 10401 | variant="destructive" | |
| 10402 | onClick={() => { | |
| 10403 | if (!pendingRevert || pendingRevert.routeThreadKey !== routeThreadKey) return; | |
| 10404 | setPendingRevert(null); | |
| 10405 | void onRevertToTurnCount(pendingRevert.turnCount, pendingRevert.messageId, true); | |
| 10406 | }} | |
| 10407 | > | |
| 10408 | Revert files too | |
| 10409 | </Button> | |
| 10403 | {activeWorktreePath !== null ? ( | |
| 10404 | <Button | |
| 10405 | variant="destructive" | |
| 10406 | onClick={() => { | |
| 10407 | if (!pendingRevert || pendingRevert.routeThreadKey !== routeThreadKey) return; | |
| 10408 | setPendingRevert(null); | |
| 10409 | void onRevertToTurnCount(pendingRevert.turnCount, pendingRevert.messageId, true); | |
| 10410 | }} | |
| 10411 | > | |
| 10412 | Revert files too | |
| 10413 | </Button> | |
| 10414 | ) : null} | |
| 10410 | 10415 | <Button |
| 10411 | 10416 | onClick={() => { |
| 10412 | 10417 | if (!pendingRevert || pendingRevert.routeThreadKey !== routeThreadKey) return; |
docs/user/composer.md+4-1| ... | ... | @@ -104,7 +104,10 @@ into a normal draft. |
| 104 | 104 | On web and desktop, choose **Edit from here** beneath a sent message to rewind |
| 105 | 105 | the conversation to before that message. Choose **Revert and keep changes** to |
| 106 | 106 | leave workspace files as they are, or **Revert files too** to restore them as well. |
| 107 | The selected prompt and its attachments return to the composer for editing and | |
| 107 | File restore is only offered for threads running in a worktree, and it is | |
| 108 | refused when another thread or agent session also uses that directory, since | |
| 109 | restoring would erase their changes. A thread that works in the project directory | |
| 110 | rewinds the conversation only. The selected prompt and its attachments return to the composer for editing and | |
| 108 | 111 | resending. Any unsent draft stays above the restored prompt. |
| 109 | 112 | |
| 110 | 113 | This removes the selected message and later conversation from the active thread |