fix(checkpoints): keep every failure record and allow chat-only restore of failed runs

Capping failures let a pruned run fall back to an earlier checkpoint, which
would undo the turns in between; failures now persist like history. A
chat-only restore leaves files alone, so a failed snapshot no longer blocks
it. Desktop edit-forks use the same per-run rule, and ARCHITECTURE.md
describes the checkpoint record.
This commit is contained in:
Cline
2026-10-21 20:15:45 -04:00
parent b6807e2ab6
commit 1690c40bfa
8 changed files with 152 additions and 54 deletions
@@ -788,6 +788,80 @@ describe("session forks", () => {
expect(ctx.restoringWorkspacePaths.size).toBe(0);
});
it("forks without restoring an earlier snapshot when the edited run's checkpoint failed", async () => {
const sourceSessionId = `source-failed-fork-${Date.now()}`;
const sourceMessages = [
{ role: "user" as const, content: "first prompt" },
{ role: "assistant" as const, content: "first response" },
{ role: "user" as const, content: "prompt to edit" },
{ role: "assistant" as const, content: "response to replace" },
];
const expectedMessages = sourceMessages.slice(0, 2);
const start = vi.fn(async () => ({ sessionId: "failed-run-fork" }));
const restore = vi.fn(async () => {
throw new Error("restore must not rewind to run 1's snapshot");
});
const ctx = {
liveSessions: new Map([
[
sourceSessionId,
{
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
},
messages: sourceMessages,
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "completed",
},
],
]),
restoringWorkspacePaths: new Set(),
...localRuntimeContext({
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
metadata: {
checkpoint: {
latest: { ref: "first", createdAt: 1, runCount: 1 },
history: [{ ref: "first", createdAt: 1, runCount: 1 }],
failures: [
{ runCount: 2, createdAt: 2, errorMessage: "snapshot failed" },
],
},
},
})),
readMessages: vi.fn(async () => expectedMessages),
restore,
start,
}),
streamIndices: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
await handleChatSessionCommand(ctx, {
action: "fork",
sessionId: sourceSessionId,
forkBeforeRunCount: 2,
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
},
});
expect(restore).not.toHaveBeenCalled();
expect(start).toHaveBeenCalledWith(
expect.objectContaining({ initialMessages: expectedMessages }),
);
});
it("keeps a full-history fork on the current workspace and cancels source questions", async () => {
const sourceSessionId = `source-full-fork-${Date.now()}`;
const sourceMessages = [
@@ -9,7 +9,6 @@ import {
createContextCompactionPrepareTurn,
createSessionCompactionState,
createUserInstructionConfigService,
findCheckpointForRun,
formatSessionPluginIssue,
getCoreBuiltinToolCatalog,
isSessionNotFoundError,
@@ -18,8 +17,8 @@ import {
projectSessionCompactionState,
RuntimeOAuthTokenManager,
readGlobalSettings,
readSessionCheckpointHistory,
resolveProviderApiKeyFromSettings,
resolveRunCheckpoint,
type SessionCompactionState,
type SessionPendingPrompt,
type SessionRecord,
@@ -2187,16 +2186,14 @@ async function handleForkUnlocked(
sessionMetadata: forkMetadata,
toolPolicies: resolveToolPolicies(forkConfig),
};
// Sessions without a checkpoint at or before the edited run (imported
// transcripts, checkpoints disabled) have no workspace state to roll back,
// so fork the trimmed messages onto the current workspace instead of
// failing the edit.
// Sessions without a usable checkpoint for the edited run (imported
// transcripts, checkpoints disabled, or a run whose snapshot failed) have
// no workspace state to roll back, so fork the trimmed messages onto the
// current workspace instead of failing the edit.
const canRestoreWorkspace =
forkBeforeRunCount !== undefined &&
findCheckpointForRun(
readSessionCheckpointHistory({ metadata: sourceMetadata }),
forkBeforeRunCount,
) !== undefined;
resolveRunCheckpoint({ metadata: sourceMetadata }, forkBeforeRunCount)
.status === "available";
if (forkBeforeRunCount !== undefined && canRestoreWorkspace) {
const cwd =
restoreWorkspacePath ||
+1
View File
@@ -136,6 +136,7 @@ Design rules:
- `core` is the app-facing orchestration layer over `agents`.
- `session/fork-metadata` owns fork ancestry and removal of inherited handoff markers; hosts retain title, transcript, and workspace-restore policies.
- `session/checkpoint-records` owns the per-run checkpoint record in `metadata.checkpoint`: each user turn ends with a checkpoint entry or a `CheckpointFailure` whose `errorMessage` hosts show as-is. `resolveRunCheckpoint` is the one rule for which snapshot a run restores to: its own checkpoint; nothing, if its snapshot failed (an earlier checkpoint would also undo the turns in between); otherwise, for runs with no record, the nearest earlier checkpoint. Chat-only restores ignore failures, since they leave files alone. Restored and edited sessions inherit both lists up to the restored run.
- `@cline/core/cloud` owns remote cloud-session state and emits immutable snapshots and events. Viewers hydrating active runs with `readMessages` reconcile canonical history at completion even if they missed the run start. Hosts supply authentication and project those snapshots into their UI; feature flags, account selection, and host persistence remain outside the controller. Importing this subpath does not initialize a local agent.
- `cloud/models` owns cloud model eligibility; `services/cloud-handoff` owns Git preflight, fingerprints, and transcript verification. Hosts own transfer orchestration, source locks, persistence, feature gating, and draft recovery.
- The cloud controller resumes suspended sessions and restores saved tasks.
@@ -71,13 +71,16 @@ describe("recording checkpoint outcomes", () => {
});
});
it("keeps only the newest failures", () => {
let metadata: CheckpointMetadata = { history: [] };
for (let runCount = 1; runCount <= 60; runCount++) {
it("keeps every failure so no failed run falls back to an earlier checkpoint", () => {
let metadata: CheckpointMetadata = recordCheckpointEntry(
{ history: [] },
entry(1),
);
for (let runCount = 2; runCount <= 60; runCount++) {
metadata = recordCheckpointFailure(metadata, failure(runCount));
}
expect(metadata.failures).toHaveLength(50);
expect(metadata.failures?.[0]?.runCount).toBe(11);
expect(resolveRunCheckpoint(session(metadata), 2).status).toBe("failed");
});
it("reads records written before failures were recorded", () => {
@@ -41,9 +41,6 @@ export type RunCheckpoint =
| { status: "failed"; errorMessage: string }
| { status: "unrecorded" };
/** Long sessions in unsupported workspaces fail every turn; keep the newest. */
const MAX_CHECKPOINT_FAILURES = 50;
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
@@ -185,7 +182,11 @@ export function recordCheckpointEntry(
return withOptionalFields(history, failures, entry);
}
/** Records a failure for its run, replacing any earlier outcome for it. */
/**
* Records a failure for its run, replacing any earlier outcome for it.
* Failures are never pruned: each one is what stops its run from falling back
* to an earlier checkpoint, so they grow with the session like `history`.
*/
export function recordCheckpointFailure(
metadata: CheckpointMetadata,
failure: CheckpointFailure,
@@ -198,9 +199,7 @@ export function recordCheckpointFailure(
(other) => other.runCount !== failure.runCount,
),
failure,
]
.sort(byRunCount)
.slice(-MAX_CHECKPOINT_FAILURES);
].sort(byRunCount);
return withOptionalFields(history, failures);
}
@@ -495,39 +495,48 @@ describe("checkpoint message trimming", () => {
]);
});
it("refuses to restore a run whose checkpoint failed rather than using an earlier one", () => {
expect(() =>
createCheckpointRestorePlan({
session: {
sessionId: "session-1",
source: "cli",
status: "running",
startedAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
interactive: true,
provider: "mock",
model: "mock",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: true,
enableSpawn: true,
enableTeams: true,
isSubagent: false,
metadata: {
checkpoint: {
latest: { ref: "aaaa", createdAt: 1, runCount: 1 },
history: [{ ref: "aaaa", createdAt: 1, runCount: 1 }],
failures: [
{ runCount: 2, createdAt: 2, errorMessage: "git broke" },
],
},
},
it("refuses to restore files for a run whose checkpoint failed, but rewinds chat", () => {
const session = {
sessionId: "session-1",
source: "cli",
status: "running",
startedAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
interactive: true,
provider: "mock",
model: "mock",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: true,
enableSpawn: true,
enableTeams: true,
isSubagent: false,
metadata: {
checkpoint: {
latest: { ref: "aaaa", createdAt: 1, runCount: 1 },
history: [{ ref: "aaaa", createdAt: 1, runCount: 1 }],
failures: [{ runCount: 2, createdAt: 2, errorMessage: "git broke" }],
},
checkpointRunCount: 2,
restoreMessages: false,
}),
},
} as const;
const messages = [
{ role: "user" as const, content: "first" },
{ role: "assistant" as const, content: "first response" },
{ role: "user" as const, content: "second" },
];
expect(() =>
createCheckpointRestorePlan({ session, messages, checkpointRunCount: 2 }),
).toThrow(
"No checkpoint was taken for run 2 in session session-1: git broke",
);
expect(
createCheckpointRestorePlan({
session,
messages,
checkpointRunCount: 2,
restoreWorkspace: false,
}).messages,
).toEqual(messages);
});
});
@@ -6,6 +6,8 @@ import { isCheckpointStashMessage } from "../hooks/checkpoint-hooks";
import type { SessionRecord } from "../types/sessions";
import {
type CheckpointEntry,
findCheckpointForRun,
readSessionCheckpointHistory,
resolveRunCheckpoint,
} from "./checkpoint-records";
import { getUserRunSpan } from "./user-run-messages";
@@ -208,20 +210,32 @@ export function createCheckpointRestorePlan(input: {
checkpointRunCount: number;
cwd?: string;
restoreMessages?: boolean;
restoreWorkspace?: boolean;
}): CheckpointRestorePlan {
const runCount = input.checkpointRunCount;
if (!Number.isInteger(runCount) || runCount < 1) {
throw new Error("checkpointRunCount must be a positive integer");
}
const resolved = resolveRunCheckpoint(input.session, runCount);
if (resolved.status !== "available") {
// Only restoring files needs the run's own snapshot. A chat-only restore
// leaves the workspace alone, so a failed snapshot does not block it; it
// keeps the nearest earlier checkpoint as its anchor, as before.
const checkpoint =
resolved.status === "available"
? resolved.checkpoint
: input.restoreWorkspace === false
? findCheckpointForRun(
readSessionCheckpointHistory(input.session),
runCount,
)
: undefined;
if (!checkpoint) {
throw new Error(
resolved.status === "failed"
? `No checkpoint was taken for run ${runCount} in session ${input.session.sessionId}: ${resolved.errorMessage}`
: `No checkpoint found at or before run ${runCount} in session ${input.session.sessionId}`,
);
}
const { checkpoint } = resolved;
const cwd = (
input.cwd?.trim() ||
input.session.cwd ||
@@ -208,6 +208,7 @@ export class SessionVersioningService {
checkpointRunCount: input.checkpointRunCount,
cwd: input.cwd,
restoreMessages,
restoreWorkspace,
});
plannedCheckpoint = plan.checkpoint;
} catch (error) {