Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 103 additions & 9 deletions apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ describe("CheckpointReactor", () => {
readonly threadWorktreePath?: string | null;
readonly threadBranch?: string | null;
readonly secondThreadSharingWorktree?: boolean;
readonly secondThreadWorktreePath?: (cwd: string) => string;
readonly localStatusRefName?: string | null;
readonly providerSessionCwd?: string;
readonly providerName?: ProviderDriverKind;
Expand Down Expand Up @@ -436,7 +437,8 @@ describe("CheckpointReactor", () => {
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
branch: options?.threadBranch ?? null,
worktreePath: options?.threadWorktreePath ?? cwd,
worktreePath:
options?.threadWorktreePath !== undefined ? options.threadWorktreePath : cwd,
createdAt,
})
.pipe(
Expand All @@ -455,7 +457,8 @@ describe("CheckpointReactor", () => {
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
branch: null,
worktreePath: options?.threadWorktreePath ?? cwd,
worktreePath:
options?.secondThreadWorktreePath?.(cwd) ?? options?.threadWorktreePath ?? cwd,
createdAt,
}),
)
Expand Down Expand Up @@ -497,6 +500,81 @@ describe("CheckpointReactor", () => {
};
}

effectIt.effect.each([
"active",
"archived",
"alias",
"nested",
"ancestor",
"project-root",
"conversation",
] as const)("preserves sibling files when reverting a shared workspace, owner=%s", (owner) =>
Effect.gen(function* () {
const harness = yield* Effect.promise(() =>
createHarness({
secondThreadSharingWorktree: true,
...(owner === "alias" || owner === "nested" || owner === "ancestor"
? {
secondThreadWorktreePath: (cwd: string) => {
if (owner === "ancestor") return NodePath.dirname(cwd);
if (owner === "nested") {
const nested = NodePath.join(cwd, "nested-owner");
NodeFS.mkdirSync(nested);
return nested;
}
const alias = `${cwd}-alias`;
NodeFS.symlinkSync(cwd, alias, "junction");
tempDirs.push(alias);
return alias;
},
}
: {}),
}),
);
const createdAt = "2026-01-01T00:00:02.000Z";
if (owner === "archived")
yield* harness.engine.dispatch({
type: "thread.archive",
commandId: CommandId.make("cmd-archive-owner"),
threadId: ThreadId.make("thread-2"),
});
if (owner === "project-root")
yield* harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.make("cmd-root-owner"),
threadId: ThreadId.make("thread-2"),
worktreePath: null,
});
const siblingFile = NodePath.join(
harness.cwd,
...(owner === "nested" ? ["nested-owner"] : []),
"sibling-work.txt",
);
NodeFS.writeFileSync(siblingFile, "sibling work\n");
yield* harness.engine.dispatch({
type: owner === "conversation" ? "thread.conversation.revert" : "thread.checkpoint.revert",
commandId: CommandId.make("cmd-shared-revert"),
threadId: ThreadId.make("thread-1"),
turnCount: 0,
createdAt,
});
yield* Effect.promise(harness.drain);
expect(NodeFS.readFileSync(siblingFile, "utf8")).toBe("sibling work\n");
expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v3\n");
const model = yield* Effect.promise(harness.readModel);
const failure = model.threads
.find((t) => t.id === "thread-1")
?.activities.find((a) => a.kind === "checkpoint.revert.failed");
if (owner === "conversation") expect(failure).toBeUndefined();
else {
expect(failure?.payload).toMatchObject({
detail: expect.stringContaining("isolated worktree"),
});
expect(harness.provider.rollbackConversation).not.toHaveBeenCalled();
}
}),
);

effectIt.effect("captures baseline and large turn summaries before completion receipts", () =>
Effect.gen(function* () {
const harness = yield* Effect.promise(() =>
Expand Down Expand Up @@ -1963,7 +2041,7 @@ describe("CheckpointReactor", () => {
});

it.each([false, true])(
"reverts without an active session using project cwd fallback: %s",
"restores files only in an isolated worktree without an active session, project cwd=%s",
async (useProjectCwd) => {
const harness = await createHarness({
hasSession: false,
Expand Down Expand Up @@ -1995,12 +2073,28 @@ describe("CheckpointReactor", () => {
}),
);

await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");
expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
threadId: ThreadId.make("thread-1"),
numTurns: 1,
});
expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v1\n");
await harness.drain();
if (useProjectCwd) {
expect(harness.provider.rollbackConversation).not.toHaveBeenCalled();
expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v3\n");
const model = await harness.readModel();
expect(model.threads[0]?.activities).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "checkpoint.revert.failed",
payload: expect.objectContaining({
detail: expect.stringContaining("isolated worktree"),
}),
}),
]),
);
} else {
expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
threadId: ThreadId.make("thread-1"),
numTurns: 1,
});
expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v1\n");
}
},
);
});
64 changes: 64 additions & 0 deletions apps/server/src/orchestration/Layers/CheckpointReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import * as Crypto from "effect/Crypto";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Option from "effect/Option";
import type * as PlatformError from "effect/PlatformError";
import * as Stream from "effect/Stream";
Expand Down Expand Up @@ -87,6 +89,8 @@ const make = Effect.gen(function* () {
const checkpointStore = yield* CheckpointStore.CheckpointStore;
const receiptBus = yield* RuntimeReceiptBus;
const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
const pullRequests = yield* PullRequestService.PullRequestService;
const startedTurns = new Map<ThreadId, TurnId>();
Expand Down Expand Up @@ -687,6 +691,55 @@ const make = Effect.gen(function* () {
});
});

// Checkpoints contain the whole checkout, so restoring a shared cwd can erase a sibling's work.
const isRestoreWorkspaceIsolated = Effect.fn("isRestoreWorkspaceIsolated")(function* (
thread: { readonly id: ThreadId; readonly worktreePath: string | null },
cwd: string,
) {
if (thread.worktreePath === null) return false;
const canonicalCwd = yield* fileSystem.realPath(cwd);
if ((yield* fileSystem.realPath(thread.worktreePath)) !== canonicalCwd) return false;
const active = yield* projectionSnapshotQuery.getShellSnapshot();
const archived = yield* projectionSnapshotQuery.getArchivedShellSnapshot();
const projects = [...active.projects, ...archived.projects];
const paths = new Set<string>();
for (const other of [...active.threads, ...archived.threads]) {
if (other.id === thread.id) continue;
const candidate =
other.worktreePath ??
projects.find((project) => project.id === other.projectId)?.workspaceRoot;
if (candidate !== undefined) paths.add(candidate);
}
for (const session of yield* providerService.listSessions()) {
if (
session.threadId !== thread.id &&
session.status !== "closed" &&
session.cwd !== undefined
)
paths.add(session.cwd);
}
for (const candidate of paths) {
const otherCwd = yield* fileSystem
.realPath(candidate)
.pipe(
Effect.catch((error) =>
error.reason._tag === "NotFound" ? Effect.succeed(null) : Effect.fail(error),
),
);
if (otherCwd === null) continue;
const isWithin = (parent: string, child: string) => {
const relative = path.relative(parent, child);
return (
relative === "" ||
(!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))
);
};
// Parent and nested owners can both have files inside the restore target.
if (isWithin(canonicalCwd, otherCwd) || isWithin(otherCwd, canonicalCwd)) return false;
}
return true;
});

const handleRevertRequested = Effect.fn("handleRevertRequested")(function* (
event: Extract<OrchestrationEvent, { type: "thread.checkpoint-revert-requested" }>,
) {
Expand Down Expand Up @@ -742,6 +795,17 @@ const make = Effect.gen(function* () {
return;
}

if (!(yield* isRestoreWorkspaceIsolated(thread, checkpointCwd))) {
yield* appendRevertFailureActivity({
threadId: thread.id,
turnCount: event.payload.turnCount,
detail:
"File restore requires an isolated worktree. This workspace may contain changes from another thread. Rewind the conversation without restoring files instead.",
createdAt: now,
}).pipe(Effect.catch(() => Effect.void));
return;
}

const targetCheckpointRef =
event.payload.turnCount === 0
? checkpointRefForThreadTurn(event.payload.threadId, 0)
Expand Down
25 changes: 15 additions & 10 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10393,20 +10393,25 @@ export default function ChatView(props: ChatViewProps) {
<AlertDialogDescription>
Rewind chat to before this message. Your prompt and attachments return to the
composer.
{activeWorktreePath === null
? " Files stay as they are because this thread shares the project directory."
: null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
<Button
variant="destructive"
onClick={() => {
if (!pendingRevert || pendingRevert.routeThreadKey !== routeThreadKey) return;
setPendingRevert(null);
void onRevertToTurnCount(pendingRevert.turnCount, pendingRevert.messageId, true);
}}
>
Revert files too
</Button>
{activeWorktreePath !== null ? (
<Button
variant="destructive"
onClick={() => {
if (!pendingRevert || pendingRevert.routeThreadKey !== routeThreadKey) return;
setPendingRevert(null);
void onRevertToTurnCount(pendingRevert.turnCount, pendingRevert.messageId, true);
}}
>
Revert files too
</Button>
) : null}
<Button
onClick={() => {
if (!pendingRevert || pendingRevert.routeThreadKey !== routeThreadKey) return;
Expand Down
5 changes: 4 additions & 1 deletion docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,10 @@ into a normal draft.
On web and desktop, choose **Edit from here** beneath a sent message to rewind
the conversation to before that message. Choose **Revert and keep changes** to
leave workspace files as they are, or **Revert files too** to restore them as well.
The selected prompt and its attachments return to the composer for editing and
File restore is only offered for threads running in a worktree, and it is
refused when another thread or agent session also uses that directory, since
restoring would erase their changes. A thread that works in the project directory
rewinds the conversation only. The selected prompt and its attachments return to the composer for editing and
resending. Any unsent draft stays above the restored prompt.

This removes the selected message and later conversation from the active thread
Expand Down
Loading