mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
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:
@@ -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 ||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user