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
194 changes: 120 additions & 74 deletions apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1683,86 +1683,132 @@ describe("CheckpointReactor", () => {
}),
);

it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => {
const harness = await createHarness();
const createdAt = "2026-01-01T00:00:00.000Z";
it.each([
{ commandType: "thread.checkpoint.revert", initializeGit: true },
{ commandType: "thread.conversation.revert", initializeGit: true },
{ commandType: "thread.conversation.revert", initializeGit: false },
] as const)(
"$commandType rewinds history with the requested filesystem behavior (git: $initializeGit)",
async ({ commandType, initializeGit }) => {
const harness = await createHarness({
initializeGit,
seedFilesystemCheckpoints: initializeGit,
});
const createdAt = "2026-01-01T00:00:00.000Z";

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.session.set",
commandId: CommandId.make("cmd-session-set"),
threadId: ThreadId.make("thread-1"),
session: {
await Effect.runPromise(
harness.engine.dispatch({
type: "thread.session.set",
commandId: CommandId.make("cmd-session-set"),
threadId: ThreadId.make("thread-1"),
status: "ready",
providerName: "codex",
runtimeMode: "approval-required",
activeTurnId: null,
lastError: null,
updatedAt: createdAt,
},
createdAt,
}),
);
session: {
threadId: ThreadId.make("thread-1"),
status: "ready",
providerName: "codex",
runtimeMode: "approval-required",
activeTurnId: null,
lastError: null,
updatedAt: createdAt,
},
createdAt,
}),
);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.diff.complete",
commandId: CommandId.make("cmd-diff-1"),
threadId: ThreadId.make("thread-1"),
turnId: asTurnId("turn-1"),
completedAt: createdAt,
checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1),
status: "ready",
files: [],
checkpointTurnCount: 1,
createdAt,
}),
);
await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.diff.complete",
commandId: CommandId.make("cmd-diff-2"),
threadId: ThreadId.make("thread-1"),
turnId: asTurnId("turn-2"),
completedAt: createdAt,
checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2),
status: "ready",
files: [],
checkpointTurnCount: 2,
createdAt,
}),
);
await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.diff.complete",
commandId: CommandId.make("cmd-diff-1"),
threadId: ThreadId.make("thread-1"),
turnId: asTurnId("turn-1"),
completedAt: createdAt,
checkpointRef: initializeGit
? checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)
: CheckpointRef.make("provider-diff:thread-1:turn-1"),
status: initializeGit ? "ready" : "missing",
files: [],
checkpointTurnCount: 1,
createdAt,
}),
);
await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.diff.complete",
commandId: CommandId.make("cmd-diff-2"),
threadId: ThreadId.make("thread-1"),
turnId: asTurnId("turn-2"),
completedAt: createdAt,
checkpointRef: initializeGit
? checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)
: CheckpointRef.make("provider-diff:thread-1:turn-2"),
status: initializeGit ? "ready" : "missing",
files: [],
checkpointTurnCount: 2,
createdAt,
}),
);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.checkpoint.revert",
commandId: CommandId.make("cmd-revert-request"),
threadId: ThreadId.make("thread-1"),
turnCount: 1,
createdAt,
}),
);
NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "staged edit\n");
if (initializeGit) {
NodeChildProcess.execFileSync("git", ["add", "README.md"], { cwd: harness.cwd });
}
NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "unstaged edit\n");
NodeFS.writeFileSync(NodePath.join(harness.cwd, "scratch.txt"), "untracked edit\n");
const indexBefore = initializeGit
? NodeChildProcess.execFileSync("git", ["ls-files", "--stage"], {
cwd: harness.cwd,
encoding: "utf8",
})
: undefined;

await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");
const thread = await waitForThread(
harness.readModel,
(entry) => entry.checkpoints.length === 1,
);
await Effect.runPromise(
harness.engine.dispatch({
type: commandType,
commandId: CommandId.make("cmd-revert-request"),
threadId: ThreadId.make("thread-1"),
turnCount: 1,
createdAt,
}),
);

expect(thread.latestTurn?.turnId).toBe("turn-1");
expect(thread.checkpoints).toHaveLength(1);
expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);
expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
threadId: ThreadId.make("thread-1"),
numTurns: 1,
});
expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n");
expect(
gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)),
).toBe(false);
});
await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");
const thread = await waitForThread(
harness.readModel,
(entry) => entry.checkpoints.length === 1,
);

expect(thread.latestTurn?.turnId).toBe("turn-1");
expect(thread.checkpoints).toHaveLength(1);
expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);
expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
threadId: ThreadId.make("thread-1"),
numTurns: 1,
});
expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe(
commandType === "thread.conversation.revert" ? "unstaged edit\n" : "v2\n",
);
if (commandType === "thread.conversation.revert") {
expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "scratch.txt"), "utf8")).toBe(
"untracked edit\n",
);
if (initializeGit) {
expect(
NodeChildProcess.execFileSync("git", ["ls-files", "--stage"], {
cwd: harness.cwd,
encoding: "utf8",
}),
).toBe(indexBefore);
}
}
if (initializeGit) {
expect(
gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)),
).toBe(false);
} else {
expect(NodeFS.existsSync(NodePath.join(harness.cwd, ".git"))).toBe(false);
}
},
);

it("executes provider revert and emits thread.reverted for claude sessions", async () => {
const harness = await createHarness({ providerName: ProviderDriverKind.make("claudeAgent") });
Expand Down
95 changes: 51 additions & 44 deletions apps/server/src/orchestration/Layers/CheckpointReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,16 +704,11 @@ const make = Effect.gen(function* () {
thread,
projects: yield* resolveThreadProjects(thread.projectId),
preferSessionRuntime: true,
});
if (!checkpointCwd) {
yield* appendRevertFailureActivity({
threadId: event.payload.threadId,
turnCount: event.payload.turnCount,
detail: "Checkpoint workspace is unavailable or is not a git repository.",
createdAt: now,
}).pipe(Effect.catch(() => Effect.void));
return;
}
}).pipe(
Effect.catch((error) =>
event.payload.restoreFiles === false ? Effect.succeed(undefined) : Effect.fail(error),
),
);

const currentTurnCount = thread.checkpoints.reduce(
(maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),
Expand All @@ -730,43 +725,55 @@ const make = Effect.gen(function* () {
return;
}

const targetCheckpointRef =
event.payload.turnCount === 0
? checkpointRefForThreadTurn(event.payload.threadId, 0)
: thread.checkpoints.find(
(checkpoint) => checkpoint.checkpointTurnCount === event.payload.turnCount,
)?.checkpointRef;
yield* providerService.assertConversationRollbackSupported(event.payload.threadId);

if (!targetCheckpointRef) {
yield* appendRevertFailureActivity({
threadId: event.payload.threadId,
turnCount: event.payload.turnCount,
detail: `Checkpoint ref for turn ${event.payload.turnCount} is unavailable in read model.`,
createdAt: now,
}).pipe(Effect.catch(() => Effect.void));
return;
}
if (event.payload.restoreFiles !== false) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!checkpointCwd) {
yield* appendRevertFailureActivity({
threadId: event.payload.threadId,
turnCount: event.payload.turnCount,
detail: "Checkpoint workspace is unavailable or is not a git repository.",
createdAt: now,
}).pipe(Effect.catch(() => Effect.void));
return;
}

yield* providerService.assertConversationRollbackSupported(event.payload.threadId);
const targetCheckpointRef =
event.payload.turnCount === 0
? checkpointRefForThreadTurn(event.payload.threadId, 0)
: thread.checkpoints.find(
(checkpoint) => checkpoint.checkpointTurnCount === event.payload.turnCount,
)?.checkpointRef;

if (!targetCheckpointRef) {
yield* appendRevertFailureActivity({
threadId: event.payload.threadId,
turnCount: event.payload.turnCount,
detail: `Checkpoint ref for turn ${event.payload.turnCount} is unavailable in read model.`,
createdAt: now,
}).pipe(Effect.catch(() => Effect.void));
return;
}

const restored = yield* checkpointStore.restoreCheckpoint({
cwd: checkpointCwd,
checkpointRef: targetCheckpointRef,
fallbackToHead: event.payload.turnCount === 0,
});
if (!restored) {
yield* appendRevertFailureActivity({
threadId: event.payload.threadId,
turnCount: event.payload.turnCount,
detail: `Filesystem checkpoint is unavailable for turn ${event.payload.turnCount}.`,
createdAt: now,
}).pipe(Effect.catch(() => Effect.void));
return;
}
const restored = yield* checkpointStore.restoreCheckpoint({
cwd: checkpointCwd,
checkpointRef: targetCheckpointRef,
fallbackToHead: event.payload.turnCount === 0,
});
if (!restored) {
yield* appendRevertFailureActivity({
threadId: event.payload.threadId,
turnCount: event.payload.turnCount,
detail: `Filesystem checkpoint is unavailable for turn ${event.payload.turnCount}.`,
createdAt: now,
}).pipe(Effect.catch(() => Effect.void));
return;
}

// Refresh the workspace entry index so the @-mention file picker
// reflects the reverted filesystem state.
yield* workspaceEntries.refresh(checkpointCwd);
// Refresh the workspace entry index so the @-mention file picker
// reflects the reverted filesystem state.
yield* workspaceEntries.refresh(checkpointCwd);
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount);
if (rolledBackTurns > 0) {
Expand All @@ -783,7 +790,7 @@ const make = Effect.gen(function* () {
}
}

if (staleCheckpointRefs.length > 0) {
if (checkpointCwd && staleCheckpointRefs.length > 0) {
yield* checkpointStore.deleteCheckpointRefs({
cwd: checkpointCwd,
checkpointRefs: staleCheckpointRefs,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1640,6 +1640,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
};
}

case "thread.conversation.revert":
case "thread.checkpoint.revert": {
yield* requireThread({
readModel,
Expand All @@ -1657,6 +1658,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
payload: {
threadId: command.threadId,
turnCount: command.turnCount,
...(command.type === "thread.conversation.revert" ? { restoreFiles: false } : {}),
createdAt: command.createdAt,
},
};
Expand Down
Loading
Loading