| 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,6 +297,7 @@ describe("CheckpointReactor", () => { |
| 297 | readonly threadWorktreePath?: string | null; | 297 | readonly threadWorktreePath?: string | null; |
| 298 | readonly threadBranch?: string | null; | 298 | readonly threadBranch?: string | null; |
| 299 | readonly secondThreadSharingWorktree?: boolean; | 299 | readonly secondThreadSharingWorktree?: boolean; |
| 300 | readonly secondThreadWorktreePath?: (cwd: string) => string; | ||
| 300 | readonly localStatusRefName?: string | null; | 301 | readonly localStatusRefName?: string | null; |
| 301 | readonly providerSessionCwd?: string; | 302 | readonly providerSessionCwd?: string; |
| 302 | readonly providerName?: ProviderDriverKind; | 303 | readonly providerName?: ProviderDriverKind; |
| ... | @@ -436,7 +437,8 @@ describe("CheckpointReactor", () => { | ... | @@ -436,7 +437,8 @@ describe("CheckpointReactor", () => { |
| 436 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 437 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 437 | runtimeMode: "approval-required", | 438 | runtimeMode: "approval-required", |
| 438 | branch: options?.threadBranch ?? null, | 439 | branch: options?.threadBranch ?? null, |
| 439 | worktreePath: options?.threadWorktreePath ?? cwd, | 440 | worktreePath: |
| 441 | options?.threadWorktreePath !== undefined ? options.threadWorktreePath : cwd, | ||
| 440 | createdAt, | 442 | createdAt, |
| 441 | }) | 443 | }) |
| 442 | .pipe( | 444 | .pipe( |
| ... | @@ -455,7 +457,8 @@ describe("CheckpointReactor", () => { | ... | @@ -455,7 +457,8 @@ describe("CheckpointReactor", () => { |
| 455 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 457 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 456 | runtimeMode: "approval-required", | 458 | runtimeMode: "approval-required", |
| 457 | branch: null, | 459 | branch: null, |
| 458 | worktreePath: options?.threadWorktreePath ?? cwd, | 460 | worktreePath: |
| 461 | options?.secondThreadWorktreePath?.(cwd) ?? options?.threadWorktreePath ?? cwd, | ||
| 459 | createdAt, | 462 | createdAt, |
| 460 | }), | 463 | }), |
| 461 | ) | 464 | ) |
| ... | @@ -497,6 +500,81 @@ describe("CheckpointReactor", () => { | ... | @@ -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 | effectIt.effect("captures baseline and large turn summaries before completion receipts", () => | 578 | effectIt.effect("captures baseline and large turn summaries before completion receipts", () => |
| 501 | Effect.gen(function* () { | 579 | Effect.gen(function* () { |
| 502 | const harness = yield* Effect.promise(() => | 580 | const harness = yield* Effect.promise(() => |
| ... | @@ -1963,7 +2041,7 @@ describe("CheckpointReactor", () => { | ... | @@ -1963,7 +2041,7 @@ describe("CheckpointReactor", () => { |
| 1963 | }); | 2041 | }); |
| 1964 | 2042 | ||
| 1965 | it.each([false, true])( | 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 | async (useProjectCwd) => { | 2045 | async (useProjectCwd) => { |
| 1968 | const harness = await createHarness({ | 2046 | const harness = await createHarness({ |
| 1969 | hasSession: false, | 2047 | hasSession: false, |
| ... | @@ -1995,12 +2073,28 @@ describe("CheckpointReactor", () => { | ... | @@ -1995,12 +2073,28 @@ describe("CheckpointReactor", () => { |
| 1995 | }), | 2073 | }), |
| 1996 | ); | 2074 | ); |
| 1997 | 2075 | ||
| 1998 | await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); | 2076 | await harness.drain(); |
| 1999 | expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ | 2077 | if (useProjectCwd) { |
| 2000 | threadId: ThreadId.make("thread-1"), | 2078 | expect(harness.provider.rollbackConversation).not.toHaveBeenCalled(); |
| 2001 | numTurns: 1, | 2079 | expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v3\n"); |
| 2002 | }); | 2080 | const model = await harness.readModel(); |
| 2003 | expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v1\n"); | 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,6 +15,8 @@ import * as Crypto from "effect/Crypto"; |
| 15 | import * as DateTime from "effect/DateTime"; | 15 | import * as DateTime from "effect/DateTime"; |
| 16 | import * as Effect from "effect/Effect"; | 16 | import * as Effect from "effect/Effect"; |
| 17 | import * as Layer from "effect/Layer"; | 17 | import * as Layer from "effect/Layer"; |
| 18 | import * as FileSystem from "effect/FileSystem"; | ||
| 19 | import * as Path from "effect/Path"; | ||
| 18 | import * as Option from "effect/Option"; | 20 | import * as Option from "effect/Option"; |
| 19 | import type * as PlatformError from "effect/PlatformError"; | 21 | import type * as PlatformError from "effect/PlatformError"; |
| 20 | import * as Stream from "effect/Stream"; | 22 | import * as Stream from "effect/Stream"; |
| ... | @@ -87,6 +89,8 @@ const make = Effect.gen(function* () { | ... | @@ -87,6 +89,8 @@ const make = Effect.gen(function* () { |
| 87 | const checkpointStore = yield* CheckpointStore.CheckpointStore; | 89 | const checkpointStore = yield* CheckpointStore.CheckpointStore; |
| 88 | const receiptBus = yield* RuntimeReceiptBus; | 90 | const receiptBus = yield* RuntimeReceiptBus; |
| 89 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | 91 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; |
| 92 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 93 | const path = yield* Path.Path; | ||
| 90 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; | 94 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 91 | const pullRequests = yield* PullRequestService.PullRequestService; | 95 | const pullRequests = yield* PullRequestService.PullRequestService; |
| 92 | const startedTurns = new Map<ThreadId, TurnId>(); | 96 | const startedTurns = new Map<ThreadId, TurnId>(); |
| ... | @@ -687,6 +691,55 @@ const make = Effect.gen(function* () { | ... | @@ -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 | const handleRevertRequested = Effect.fn("handleRevertRequested")(function* ( | 743 | const handleRevertRequested = Effect.fn("handleRevertRequested")(function* ( |
| 691 | event: Extract<OrchestrationEvent, { type: "thread.checkpoint-revert-requested" }>, | 744 | event: Extract<OrchestrationEvent, { type: "thread.checkpoint-revert-requested" }>, |
| 692 | ) { | 745 | ) { |
| ... | @@ -742,6 +795,17 @@ const make = Effect.gen(function* () { | ... | @@ -742,6 +795,17 @@ const make = Effect.gen(function* () { |
| 742 | return; | 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 | const targetCheckpointRef = | 809 | const targetCheckpointRef = |
| 746 | event.payload.turnCount === 0 | 810 | event.payload.turnCount === 0 |
| 747 | ? checkpointRefForThreadTurn(event.payload.threadId, 0) | 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,20 +10393,25 @@ export default function ChatView(props: ChatViewProps) { |
| 10393 | <AlertDialogDescription> | 10393 | <AlertDialogDescription> |
| 10394 | Rewind chat to before this message. Your prompt and attachments return to the | 10394 | Rewind chat to before this message. Your prompt and attachments return to the |
| 10395 | composer. | 10395 | composer. |
| 10396 | {activeWorktreePath === null | ||
| 10397 | ? " Files stay as they are because this thread shares the project directory." | ||
| 10398 | : null} | ||
| 10396 | </AlertDialogDescription> | 10399 | </AlertDialogDescription> |
| 10397 | </AlertDialogHeader> | 10400 | </AlertDialogHeader> |
| 10398 | <AlertDialogFooter> | 10401 | <AlertDialogFooter> |
| 10399 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> | 10402 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> |
| 10400 | <Button | 10403 | {activeWorktreePath !== null ? ( |
| 10401 | variant="destructive" | 10404 | <Button |
| 10402 | onClick={() => { | 10405 | variant="destructive" |
| 10403 | if (!pendingRevert || pendingRevert.routeThreadKey !== routeThreadKey) return; | 10406 | onClick={() => { |
| 10404 | setPendingRevert(null); | 10407 | if (!pendingRevert || pendingRevert.routeThreadKey !== routeThreadKey) return; |
| 10405 | void onRevertToTurnCount(pendingRevert.turnCount, pendingRevert.messageId, true); | 10408 | setPendingRevert(null); |
| 10406 | }} | 10409 | void onRevertToTurnCount(pendingRevert.turnCount, pendingRevert.messageId, true); |
| 10407 | > | 10410 | }} |
| 10408 | Revert files too | 10411 | > |
| 10409 | </Button> | 10412 | Revert files too |
| 10413 | </Button> | ||
| 10414 | ) : null} | ||
| 10410 | <Button | 10415 | <Button |
| 10411 | onClick={() => { | 10416 | onClick={() => { |
| 10412 | if (!pendingRevert || pendingRevert.routeThreadKey !== routeThreadKey) return; | 10417 | if (!pendingRevert || pendingRevert.routeThreadKey !== routeThreadKey) return; |
docs/user/composer.md+4-1| ... | @@ -104,7 +104,10 @@ into a normal draft. | ... | @@ -104,7 +104,10 @@ into a normal draft. |
| 104 | On web and desktop, choose **Edit from here** beneath a sent message to rewind | 104 | On web and desktop, choose **Edit from here** beneath a sent message to rewind |
| 105 | the conversation to before that message. Choose **Revert and keep changes** to | 105 | the conversation to before that message. Choose **Revert and keep changes** to |
| 106 | leave workspace files as they are, or **Revert files too** to restore them as well. | 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 | resending. Any unsent draft stays above the restored prompt. | 111 | resending. Any unsent draft stays above the restored prompt. |
| 109 | 112 | ||
| 110 | This removes the selected message and later conversation from the active thread | 113 | This removes the selected message and later conversation from the active thread |