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
Original file line number Diff line number Diff line change
Expand Up @@ -888,7 +888,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
);

it.live(
"appends checkpoint.revert.failed activity when revert is requested without an active session",
"appends checkpoint.revert.failed activity when revert is requested without a provider binding",
() =>
withHarness((harness) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -917,7 +917,7 @@ it.live(
assert.equal(
String(
(failureActivity?.payload as { readonly detail?: string } | undefined)?.detail,
).includes("No active provider session"),
).includes("no persisted provider binding exists"),
true,
);
}),
Expand Down
25 changes: 25 additions & 0 deletions apps/server/src/claudeHistoryWorker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { forkSession, getSessionMessages } from "@anthropic-ai/claude-agent-sdk";
import * as Schema from "effect/Schema";

// A separate process gives SDK history helpers the provider's environment without
// mutating the server's environment. This entry is bundled alongside the server.
const [method, sessionId, rawOptions] = process.argv.slice(2);
const options = Schema.decodeSync(
Schema.fromJsonString(
Schema.Struct({
dir: Schema.optionalKey(Schema.String),
includeSystemMessages: Schema.optionalKey(Schema.Boolean),
upToMessageId: Schema.optionalKey(Schema.String),
}),
),
)(rawOptions ?? "{}");
if (!sessionId) throw new Error("Claude history session id is required.");
const result =
method === "getSessionMessages"
? await getSessionMessages(sessionId, options)
: method === "forkSession"
? await forkSession(sessionId, options)
: (() => {
throw new Error("Unknown Claude history operation.");
})();
process.stdout.write(JSON.stringify(result));
60 changes: 39 additions & 21 deletions apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1916,27 +1916,45 @@ describe("CheckpointReactor", () => {
});
});

it("appends an error activity when revert is requested without an active session", async () => {
const harness = await createHarness({ hasSession: false });
const createdAt = "2026-01-01T00:00:00.000Z";

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.checkpoint.revert",
commandId: CommandId.make("cmd-revert-no-session"),
threadId: ThreadId.make("thread-1"),
turnCount: 1,
createdAt,
}),
);
it.each([false, true])(
"reverts without an active session using project cwd fallback: %s",
async (useProjectCwd) => {
const harness = await createHarness({
hasSession: false,
...(useProjectCwd ? { threadWorktreePath: null } : {}),
});
const createdAt = "2026-01-01T00:00:00.000Z";

const thread = await waitForThread(harness.readModel, (entry) =>
entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"),
);
await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.diff.complete",
commandId: CommandId.make("cmd-diff-before-session-recovery"),
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.checkpoint.revert",
commandId: CommandId.make("cmd-revert-no-session"),
threadId: ThreadId.make("thread-1"),
turnCount: 0,
createdAt,
}),
);

expect(thread.activities.some((activity) => activity.kind === "checkpoint.revert.failed")).toBe(
true,
);
expect(harness.provider.rollbackConversation).not.toHaveBeenCalled();
});
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");
},
);
});
28 changes: 12 additions & 16 deletions apps/server/src/orchestration/Layers/CheckpointReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,21 +699,17 @@ const make = Effect.gen(function* () {
return;
}

const sessionRuntime = yield* resolveSessionRuntimeForThread(event.payload.threadId);
if (Option.isNone(sessionRuntime)) {
yield* appendRevertFailureActivity({
threadId: event.payload.threadId,
turnCount: event.payload.turnCount,
detail: "No active provider session with workspace cwd is bound to this thread.",
createdAt: now,
}).pipe(Effect.catch(() => Effect.void));
return;
}
if (!(yield* checkpointStore.isGitRepository(sessionRuntime.value.cwd))) {
const checkpointCwd = yield* resolveCheckpointCwd({
threadId: event.payload.threadId,
thread,
projects: yield* resolveThreadProjects(thread.projectId),
preferSessionRuntime: true,
});
if (!checkpointCwd) {
yield* appendRevertFailureActivity({
threadId: event.payload.threadId,
turnCount: event.payload.turnCount,
detail: "Checkpoints are unavailable because this project is not a git repository.",
detail: "Checkpoint workspace is unavailable or is not a git repository.",
createdAt: now,
}).pipe(Effect.catch(() => Effect.void));
return;
Expand Down Expand Up @@ -754,7 +750,7 @@ const make = Effect.gen(function* () {
yield* providerService.assertConversationRollbackSupported(event.payload.threadId);

const restored = yield* checkpointStore.restoreCheckpoint({
cwd: sessionRuntime.value.cwd,
cwd: checkpointCwd,
checkpointRef: targetCheckpointRef,
fallbackToHead: event.payload.turnCount === 0,
});
Expand All @@ -770,12 +766,12 @@ const make = Effect.gen(function* () {

// Refresh the workspace entry index so the @-mention file picker
// reflects the reverted filesystem state.
yield* workspaceEntries.refresh(sessionRuntime.value.cwd);
yield* workspaceEntries.refresh(checkpointCwd);

const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount);
if (rolledBackTurns > 0) {
yield* providerService.rollbackConversation({
threadId: sessionRuntime.value.threadId,
threadId: event.payload.threadId,
numTurns: rolledBackTurns,
});
}
Expand All @@ -789,7 +785,7 @@ const make = Effect.gen(function* () {

if (staleCheckpointRefs.length > 0) {
yield* checkpointStore.deleteCheckpointRefs({
cwd: sessionRuntime.value.cwd,
cwd: checkpointCwd,
checkpointRefs: staleCheckpointRefs,
});
}
Expand Down
Loading
Loading