feat(checkpoints): explain why a message has no workspace checkpoint

This commit is contained in:
Cline
2026-10-21 05:04:29 -04:00
parent e9dafbb764
commit 095cd88ed5
10 changed files with 503 additions and 50 deletions
+26 -3
View File
@@ -19,9 +19,32 @@ service CheckpointsService {
// "View Changes" button only when there is something to show.
rpc checkpointLatestChangesCount(EmptyRequest) returns (Int64);
// Reports whether Reset Code can restore the workspace from the user
// message with the given ts. Asked when the user opens a message for
// editing, so the answer reflects the checkpoints that exist right then.
rpc checkpointExistsForMessage(Int64Request) returns (Boolean);
// message with the given ts, and if not, why not. Asked when the user opens
// a message for editing, so the answer reflects the checkpoints that exist
// right then.
rpc checkpointExistsForMessage(Int64Request) returns (CheckpointExistsForMessageResponse);
}
// Why Reset Code cannot restore the workspace from a user message. Kept in
// sync with the webview's WorkspaceRestoreAvailability reasons.
enum CheckpointUnavailableReason {
CHECKPOINT_UNAVAILABLE_REASON_UNSPECIFIED = 0;
// Checkpoints are disabled in settings right now.
CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINTS_DISABLED = 1;
// The workspace was not a git repository when the turn ran, so no
// checkpoint could be taken. Initializing git enables future checkpoints.
CHECKPOINT_UNAVAILABLE_REASON_NO_GIT_REPOSITORY = 2;
// Checkpoint creation was attempted for the turn but failed.
CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINT_FAILED = 3;
// The turn's checkpoint could not be located (for example, the transcript
// no longer maps to it after a reload). No more specific cause is known.
CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINT_UNAVAILABLE = 4;
}
message CheckpointExistsForMessageResponse {
bool exists = 1;
// Set only when exists is false.
CheckpointUnavailableReason reason = 2;
}
message CheckpointRestoreRequest {
@@ -1,12 +1,30 @@
import { Boolean, Int64Request } from "@shared/proto/cline/common"
import { CheckpointExistsForMessageResponse, CheckpointUnavailableReason } from "@shared/proto/cline/checkpoints"
import { Int64Request } from "@shared/proto/cline/common"
import { Controller } from ".."
const REASON_BY_NAME = {
checkpoints_disabled: CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINTS_DISABLED,
no_git_repository: CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_NO_GIT_REPOSITORY,
checkpoint_failed: CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINT_FAILED,
checkpoint_unavailable: CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINT_UNAVAILABLE,
} as const
/**
* Reports whether Reset Code can restore the workspace from the user message
* with the given ts. The webview asks when the user opens a message for
* editing, so the answer reflects the checkpoints that exist at that moment
* rather than a snapshot taken when the turn ended.
* with the given ts — and if not, why not, so the webview can explain it
* instead of showing one generic tooltip. The webview asks when the user
* opens a message for editing, so the answer reflects the checkpoints that
* exist at that moment rather than a snapshot taken when the turn ended.
*/
export async function checkpointExistsForMessage(controller: Controller, request: Int64Request): Promise<Boolean> {
return Boolean.create({ value: await controller.hasWorkspaceCheckpointForMessage(request.value) })
export async function checkpointExistsForMessage(
controller: Controller,
request: Int64Request,
): Promise<CheckpointExistsForMessageResponse> {
const result = await controller.hasWorkspaceCheckpointForMessage(request.value)
return CheckpointExistsForMessageResponse.create({
exists: result.exists,
reason: result.exists
? CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_UNSPECIFIED
: REASON_BY_NAME[result.reason],
})
}
+88 -6
View File
@@ -531,6 +531,73 @@ describe("hasWorkspaceCheckpointForMessage", () => {
{ role: "user", content: "continue" },
]
it("names a recorded no-git skip for the exact run", async () => {
const controller = Object.assign(Object.create(SdkController.prototype), {
task: { taskId: "task-a", messageStateHandler: { getClineMessages: () => messages } },
sessions: {
getActiveSession: () => ({
sessionId: "task-a",
sdkHost: {
get: async () => ({
cwd: "C:/work",
metadata: {
checkpointSkips: [{ reason: "no_git_repository", runCount: 1, createdAt: 1 }],
},
}),
readLiveMessages: vi.fn().mockResolvedValue(sdkMessages),
readMessages: vi.fn().mockResolvedValue([]),
},
}),
},
})
await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 1)).resolves.toEqual({
exists: false,
reason: "no_git_repository",
})
})
it("names a recorded failed skip for the exact run", async () => {
const controller = Object.assign(Object.create(SdkController.prototype), {
task: { taskId: "task-a", messageStateHandler: { getClineMessages: () => messages } },
sessions: {
getActiveSession: () => ({
sessionId: "task-a",
sdkHost: {
get: async () => ({
cwd: "C:/work",
metadata: {
checkpointSkips: [{ reason: "checkpoint_failed", runCount: 1, createdAt: 1 }],
},
}),
readLiveMessages: vi.fn().mockResolvedValue(sdkMessages),
readMessages: vi.fn().mockResolvedValue([]),
},
}),
},
})
await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 1)).resolves.toEqual({
exists: false,
reason: "checkpoint_failed",
})
})
it("reports checkpoints_disabled from the current setting without a lookup", async () => {
const createRemoteConfigAwareSessionHost = vi.fn()
const controller = {
task: { taskId: "task-a", messageStateHandler: { getClineMessages: () => messages } },
sessions: { getActiveSession: () => undefined, createRemoteConfigAwareSessionHost },
stateManager: { getGlobalSettingsKey: () => false },
}
await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 1)).resolves.toEqual({
exists: false,
reason: "checkpoints_disabled",
})
expect(createRemoteConfigAwareSessionHost).not.toHaveBeenCalled()
})
it("reads the live conversation of the active session", async () => {
const readLiveMessages = vi.fn().mockResolvedValue(sdkMessages)
const readMessages = vi.fn().mockResolvedValue([])
@@ -550,8 +617,13 @@ describe("hasWorkspaceCheckpointForMessage", () => {
},
}
await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 3)).resolves.toBe(true)
await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 1)).resolves.toBe(false)
await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 3)).resolves.toEqual({
exists: true,
})
await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 1)).resolves.toEqual({
exists: false,
reason: "checkpoint_unavailable",
})
expect(readLiveMessages).toHaveBeenCalledWith("task-a")
expect(readMessages).not.toHaveBeenCalled()
})
@@ -588,7 +660,9 @@ describe("hasWorkspaceCheckpointForMessage", () => {
resolveContextMentions: async (text: string) => text,
})
await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 3)).resolves.toBe(true)
await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 3)).resolves.toEqual({
exists: true,
})
await expect(
SdkController.prototype.editMessageAndRegenerate.call(controller as never, {
messageTs: 3,
@@ -676,7 +750,9 @@ describe("hasWorkspaceCheckpointForMessage", () => {
createRemoteConfigAwareSessionHost: vi.fn().mockResolvedValue(tempHost),
}
await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 1)).resolves.toBe(true)
await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 1)).resolves.toEqual({
exists: true,
})
expect(tempHost.get).toHaveBeenCalledWith("task-a")
expect(tempHost.dispose).toHaveBeenCalledWith("workspaceCheckpointForMessage")
})
@@ -688,7 +764,10 @@ describe("hasWorkspaceCheckpointForMessage", () => {
createRemoteConfigAwareSessionHost: vi.fn().mockRejectedValue(new Error("host unavailable")),
}
await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 1)).resolves.toBe(false)
await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 1)).resolves.toEqual({
exists: false,
reason: "checkpoint_unavailable",
})
})
it("reports no checkpoint for messages that did not start a run", async () => {
@@ -704,7 +783,10 @@ describe("hasWorkspaceCheckpointForMessage", () => {
createRemoteConfigAwareSessionHost,
}
await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 3)).resolves.toBe(false)
await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 3)).resolves.toEqual({
exists: false,
reason: "checkpoint_unavailable",
})
expect(createRemoteConfigAwareSessionHost).not.toHaveBeenCalled()
})
})
+68 -6
View File
@@ -4,8 +4,10 @@
// Controller but delegates session lifecycle (initTask, askResponse,
// cancelTask, …) to the Cline SDK (@cline/core) and bridges SDK events to
// the webview's gRPC streams.
import { execFile } from "node:child_process"
import * as fs from "node:fs/promises"
import * as path from "node:path"
import { promisify } from "node:util"
import {
type CompareCheckpointResult,
createRestoredCheckpointMetadata,
@@ -16,6 +18,7 @@ import {
loadRulesForSystemPromptFromRecords,
type PreparedRemoteConfigCoreIntegration,
type RuleConfig,
readCheckpointSkips,
readSessionCheckpointHistory,
resolveDefaultMcpSettingsPath,
type SessionHistoryRecord,
@@ -119,6 +122,8 @@ import type { VscodeTerminalExecutionMode } from "./vscode-terminal-execution-mo
import { WebviewGrpcBridge } from "./webview-grpc-bridge"
import { resolveWorkspaceManagerPaths, resolveWorkspaceRootPath } from "./workspace-root"
const execFileAsync = promisify(execFile)
/**
* Log a stub warning and return undefined.
*/
@@ -2084,16 +2089,24 @@ export class Controller {
* live session; then, as in editMessageAndRegenerate, a temporary host is
* created for the read and disposed afterwards.
*/
async hasWorkspaceCheckpointForMessage(messageTs: number): Promise<boolean> {
async hasWorkspaceCheckpointForMessage(
messageTs: number,
): Promise<
| { exists: true }
| { exists: false; reason: "checkpoints_disabled" | "no_git_repository" | "checkpoint_failed" | "checkpoint_unavailable" }
> {
if (this.stateManager?.getGlobalSettingsKey?.("enableCheckpointsSetting") === false) {
return { exists: false, reason: "checkpoints_disabled" }
}
const activeSession = this.sessions.getActiveSession()
const currentTask = this.task
if (!currentTask) {
return false
return { exists: false, reason: "checkpoint_unavailable" }
}
const clineMessages = currentTask.messageStateHandler.getClineMessages()
const targetIndex = clineMessages.findIndex((message) => message.ts === messageTs)
if (targetIndex === -1 || getCheckpointRunCountForMessage(clineMessages, targetIndex) === undefined) {
return false
return { exists: false, reason: "checkpoint_unavailable" }
}
const userOrdinal = clineMessages.slice(0, targetIndex + 1).filter(isVisibleCheckpointUserMessage).length
const sessionId = activeSession?.sessionId ?? currentTask.taskId
@@ -2104,18 +2117,33 @@ export class Controller {
sessionHost.get(sessionId),
readCurrentMessages(sessionHost, sessionId) as Promise<SdkUserMessage[]>,
])
const metadata = sessionRecord?.metadata as Record<string, unknown> | undefined
const sdkIndex = findSdkUserMessageIndexByOrdinal(sdkMessages, userOrdinal)
if (sdkIndex === -1) {
return false
return {
exists: false,
reason: await this.resolveCheckpointUnavailableReason(metadata, undefined, sessionRecord, () =>
this.getWorkspaceRoot(),
),
}
}
const runCount = getSdkCheckpointRunCountForMessageIndex(sdkMessages, sdkIndex)
return (
if (
runCount !== undefined &&
findCheckpointForRun(readSessionCheckpointHistory(sessionRecord), runCount) !== undefined
) {
return { exists: true }
}
const resolvedReason = await this.resolveCheckpointUnavailableReason(metadata, runCount, sessionRecord, () =>
this.getWorkspaceRoot(),
)
return {
exists: false,
reason: resolvedReason,
}
} catch (error) {
Logger.debug(`[SdkController] Failed to resolve workspace checkpoint for message ${messageTs}: ${error}`)
return false
return { exists: false, reason: "checkpoint_unavailable" }
} finally {
try {
await tempHost?.dispose("workspaceCheckpointForMessage")
@@ -2125,6 +2153,40 @@ export class Controller {
}
}
/**
* Names why a turn has no checkpoint. A skip recorded for the exact run
* wins; failing that, the latest recorded skip explains sessions whose
* older turns all skipped (e.g. every turn ran outside a git repo). With
* no record — turns from before reasons were recorded, or a transcript
* that no longer maps to the session — a live probe distinguishes a
* non-git workspace right now from the generic leftover case.
*/
async resolveCheckpointUnavailableReason(
metadata: Record<string, unknown> | undefined,
runCount: number | undefined,
sessionRecord: { cwd?: string; workspaceRoot?: string } | undefined,
fallbackCwd?: () => Promise<string>,
): Promise<"no_git_repository" | "checkpoint_failed" | "checkpoint_unavailable"> {
const skips = readCheckpointSkips(metadata)
const exact = runCount === undefined ? undefined : skips.find((skip) => skip.runCount === runCount)
const recorded = exact ?? skips.at(-1)
if (recorded) {
return recorded.reason
}
const cwd = sessionRecord?.cwd?.trim() || sessionRecord?.workspaceRoot?.trim() || (fallbackCwd ? await fallbackCwd() : "")
try {
const { stdout } = await execFileAsync("git", ["-C", cwd, "rev-parse", "--is-inside-work-tree"], {
windowsHide: true,
})
if (stdout.trim() !== "true") {
return "no_git_repository"
}
} catch {
return "no_git_repository"
}
return "checkpoint_unavailable"
}
/**
* Gates the "View Changes" button on the completion row: the number of
* files changed since the latest checkpoint, or 0 when nothing can be
@@ -173,6 +173,39 @@ export interface CheckpointEntry {
kind?: "stash" | "commit"
}
export type CheckpointSkipReason = "no_git_repository" | "checkpoint_failed"
export interface CheckpointSkip {
reason: CheckpointSkipReason
runCount?: number
createdAt: number
}
export function readCheckpointSkips(metadata: Record<string, unknown> | undefined): CheckpointSkip[] {
const candidate = metadata?.checkpointSkips
if (!Array.isArray(candidate)) {
return []
}
return candidate.flatMap((entry): CheckpointSkip[] => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return []
}
const record = entry as Partial<CheckpointSkip>
if (record.reason !== "no_git_repository" && record.reason !== "checkpoint_failed") {
return []
}
const createdAt = Number(record.createdAt ?? 0)
if (!Number.isFinite(createdAt)) {
return []
}
const runCount =
typeof record.runCount === "number" && Number.isInteger(record.runCount) && record.runCount >= 1
? record.runCount
: undefined
return [{ reason: record.reason, createdAt, ...(runCount ? { runCount } : {}) }]
})
}
export function readSessionCheckpointHistory(session: { metadata?: Record<string, unknown> } | undefined): CheckpointEntry[] {
const checkpoint =
session?.metadata?.checkpoint &&
@@ -1,3 +1,4 @@
import { CheckpointUnavailableReason } from "@shared/proto/cline/checkpoints"
import { Int64Request } from "@shared/proto/cline/common"
import { EditMessageAndRegenerateRequest } from "@shared/proto/cline/task"
import type React from "react"
@@ -24,10 +25,26 @@ interface UserMessageProps {
* checkpoint is looked up when the editor opens, so the button is pending
* until the answer arrives.
*/
type WorkspaceRestoreUnavailableReason =
| "checkpoints_disabled"
| "no_git_repository"
| "checkpoint_failed"
| "checkpoint_unavailable"
type WorkspaceRestoreAvailability =
| { state: "pending" }
| { state: "available" }
| { state: "unavailable"; reason: "checkpoints_disabled" | "checkpoint_unavailable" }
| { state: "unavailable"; reason: WorkspaceRestoreUnavailableReason }
const WORKSPACE_RESTORE_UNAVAILABLE_TOOLTIPS: Record<WorkspaceRestoreUnavailableReason, string> = {
checkpoints_disabled:
"No checkpoint is available for this message. Enable Checkpoints in Settings to create checkpoints for future messages.",
no_git_repository:
"No workspace checkpoint was created for this message because the workspace is not a git repository. Initialize git (git init) to create checkpoints for future messages.",
checkpoint_failed:
"No workspace checkpoint was created for this message because checkpoint creation failed. Check that git is working in this workspace; future messages will retry.",
checkpoint_unavailable: "No workspace checkpoint was created for this message.",
}
const WORKSPACE_RESTORE_TOOLTIPS: Record<WorkspaceRestoreAvailability["state"], string> = {
pending: "Checking for a workspace checkpoint…",
@@ -35,6 +52,15 @@ const WORKSPACE_RESTORE_TOOLTIPS: Record<WorkspaceRestoreAvailability["state"],
unavailable: "No workspace checkpoint was created for this message.",
}
const CHECKPOINT_REASON_BY_PROTO: Record<CheckpointUnavailableReason, WorkspaceRestoreUnavailableReason> = {
[CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_UNSPECIFIED]: "checkpoint_unavailable",
[CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINTS_DISABLED]: "checkpoints_disabled",
[CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_NO_GIT_REPOSITORY]: "no_git_repository",
[CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINT_FAILED]: "checkpoint_failed",
[CheckpointUnavailableReason.CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINT_UNAVAILABLE]: "checkpoint_unavailable",
[CheckpointUnavailableReason.UNRECOGNIZED]: "checkpoint_unavailable",
}
const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageTs, canRestoreWorkspace }) => {
const { navigateToSettings, enableCheckpointsSetting } = useExtensionState()
const [isEditing, setIsEditing] = useState(false)
@@ -44,41 +70,54 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
const [savingMode, setSavingMode] = useState<"chat" | "workspace" | undefined>()
const [errorMessage, setErrorMessage] = useState<string | undefined>()
const [workspaceRestorePopoverOpen, setWorkspaceRestorePopoverOpen] = useState(false)
const [hasWorkspaceCheckpoint, setHasWorkspaceCheckpoint] = useState<boolean | undefined>()
const [checkpointLookup, setCheckpointLookup] = useState<
{ exists: boolean; reason?: WorkspaceRestoreUnavailableReason } | undefined
>()
const highlightedText = useMemo(() => highlightText(text), [text])
const workspaceRestoreAvailability: WorkspaceRestoreAvailability | undefined = !canRestoreWorkspace
? undefined
: hasWorkspaceCheckpoint === undefined
: checkpointLookup === undefined
? { state: "pending" }
: hasWorkspaceCheckpoint
: checkpointLookup.exists
? { state: "available" }
: {
state: "unavailable",
reason: enableCheckpointsSetting === false ? "checkpoints_disabled" : "checkpoint_unavailable",
// The backend reports checkpoints_disabled itself; the current
// setting is only a fallback for older hosts that omit a reason.
reason:
checkpointLookup.reason ??
(enableCheckpointsSetting === false ? "checkpoints_disabled" : "checkpoint_unavailable"),
}
const workspaceRestoreTooltip = workspaceRestoreAvailability && WORKSPACE_RESTORE_TOOLTIPS[workspaceRestoreAvailability.state]
const workspaceRestoreTooltip =
workspaceRestoreAvailability &&
(workspaceRestoreAvailability.state === "unavailable"
? WORKSPACE_RESTORE_UNAVAILABLE_TOOLTIPS[workspaceRestoreAvailability.reason]
: WORKSPACE_RESTORE_TOOLTIPS[workspaceRestoreAvailability.state])
const workspaceRestorePending = workspaceRestoreAvailability?.state === "pending"
// The checkpoint is looked up when the user opens the editor, not pushed
// with every state update, so the answer covers the checkpoint written for
// the latest turn. A failed lookup is reported as no checkpoint.
useEffect(() => {
setHasWorkspaceCheckpoint(undefined)
setCheckpointLookup(undefined)
if (!isEditing || !canRestoreWorkspace || messageTs === undefined) {
return
}
let cancelled = false
CheckpointsServiceClient.checkpointExistsForMessage(Int64Request.create({ value: messageTs }))
.then(
(result) => result.value,
(result) => ({
exists: result.exists,
reason: result.exists ? undefined : CHECKPOINT_REASON_BY_PROTO[result.reason],
}),
(error) => {
console.error("Failed to look up the workspace checkpoint for this message:", error)
return false
return { exists: false, reason: undefined }
},
)
.then((exists) => {
.then((lookup) => {
if (!cancelled) {
setHasWorkspaceCheckpoint(exists)
setCheckpointLookup(lookup)
}
})
return () => {
@@ -47,7 +47,7 @@ describe("UserMessage – IME composition handling", () => {
},
)
vi.mocked(TaskServiceClient.editMessageAndRegenerate).mockResolvedValue({})
vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ value: true })
vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ exists: true, reason: 0 })
})
it("does NOT send when IME composition Enter is pressed while editing", () => {
@@ -136,7 +136,7 @@ describe("UserMessage – IME composition handling", () => {
it("shows Reset Code as busy and does not restore until the checkpoint lookup answers", async () => {
const user = userEvent.setup()
let answer: (result: { value: boolean }) => void = () => {}
let answer: (result: { exists: boolean; reason: number }) => void = () => {}
vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockReturnValue(
new Promise((resolve) => {
answer = resolve
@@ -153,7 +153,7 @@ describe("UserMessage – IME composition handling", () => {
await user.click(resetCode)
expect(TaskServiceClient.editMessageAndRegenerate).not.toHaveBeenCalled()
act(() => answer({ value: true }))
act(() => answer({ exists: true, reason: 0 }))
await waitFor(() => expect(resetCode).not.toHaveAttribute("aria-busy"))
expect(resetCode).not.toHaveAttribute("aria-disabled")
await user.click(resetCode)
@@ -166,7 +166,7 @@ describe("UserMessage – IME composition handling", () => {
it("keeps Reset Chat enabled and disables Reset Code when checkpoints were off", async () => {
const user = userEvent.setup()
enableCheckpointsSetting = false
vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ value: false })
vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ exists: false, reason: 1 })
render(<UserMessage canRestoreWorkspace messageTs={123} text="Update this" />)
await user.click(screen.getByText("Update this"))
@@ -184,7 +184,7 @@ describe("UserMessage – IME composition handling", () => {
it("opens the disabled Reset Code explanation from the keyboard", async () => {
const user = userEvent.setup()
enableCheckpointsSetting = false
vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ value: false })
vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ exists: false, reason: 1 })
render(<UserMessage canRestoreWorkspace messageTs={123} text="Update this" />)
await user.click(screen.getByText("Update this"))
@@ -202,7 +202,8 @@ describe("UserMessage – IME composition handling", () => {
it("explains when checkpoint creation was enabled but no checkpoint exists", async () => {
const user = userEvent.setup()
vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ value: false })
// CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINT_UNAVAILABLE
vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ exists: false, reason: 4 })
render(<UserMessage canRestoreWorkspace messageTs={123} text="Update this" />)
await user.click(screen.getByText("Update this"))
@@ -213,6 +214,34 @@ describe("UserMessage – IME composition handling", () => {
expect(await screen.findByText("No workspace checkpoint was created for this message.")).toBeInTheDocument()
})
it("names a non-git workspace as the reason no checkpoint exists", async () => {
const user = userEvent.setup()
// CHECKPOINT_UNAVAILABLE_REASON_NO_GIT_REPOSITORY
vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ exists: false, reason: 2 })
render(<UserMessage canRestoreWorkspace messageTs={123} text="Update this" />)
await user.click(screen.getByText("Update this"))
const resetCode = screen.getByRole("button", { name: "Reset Code" })
await waitFor(() => expect(resetCode).not.toHaveAttribute("aria-busy"))
expect(resetCode).toHaveAttribute("aria-disabled", "true")
await user.hover(resetCode)
expect(await screen.findByText(/not a git repository/)).toBeInTheDocument()
})
it("names a failed checkpoint creation as the reason no checkpoint exists", async () => {
const user = userEvent.setup()
// CHECKPOINT_UNAVAILABLE_REASON_CHECKPOINT_FAILED
vi.mocked(CheckpointsServiceClient.checkpointExistsForMessage).mockResolvedValue({ exists: false, reason: 3 })
render(<UserMessage canRestoreWorkspace messageTs={123} text="Update this" />)
await user.click(screen.getByText("Update this"))
const resetCode = screen.getByRole("button", { name: "Reset Code" })
await waitFor(() => expect(resetCode).not.toHaveAttribute("aria-busy"))
expect(resetCode).toHaveAttribute("aria-disabled", "true")
await user.hover(resetCode)
expect(await screen.findByText(/checkpoint creation failed/)).toBeInTheDocument()
})
it("hides Reset Code for messages that did not start a run", async () => {
const user = userEvent.setup()
render(<UserMessage messageTs={123} text="Update this" />)
@@ -12,6 +12,7 @@ import {
checkpointScratchDir,
createCheckpointHooks,
deleteCheckpointRefs,
readCheckpointSkips,
} from "./checkpoint-hooks";
const execFile = promisify(execFileCallback);
@@ -596,6 +597,58 @@ describe("createCheckpointHooks", () => {
).toEqual([1, 2]);
});
it("records a no_git_repository skip when the workspace is not a git repository", async () => {
const cwd = await mkdtemp(join(tmpdir(), "core-checkpoint-nogit-"));
let metadata: Record<string, unknown> | undefined;
try {
const hooks = createCheckpointHooks({
cwd,
sessionId: "sess_nogit",
readSessionMetadata: async () => metadata,
writeSessionMetadata: async (next) => {
metadata = next;
},
});
await runCheckpointHooks(hooks);
expect(metadata?.checkpoint).toBeUndefined();
expect(metadata?.checkpointSkips).toEqual([
expect.objectContaining({ reason: "no_git_repository", runCount: 1 }),
]);
expect(readCheckpointSkips(metadata)).toEqual([
expect.objectContaining({ reason: "no_git_repository", runCount: 1 }),
]);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
it("records a checkpoint_failed skip when a custom checkpoint returns nothing", async () => {
const cwd = await createGitRepo();
let metadata: Record<string, unknown> | undefined;
try {
const hooks = createCheckpointHooks({
cwd,
sessionId: "sess_custom_skip",
createCheckpoint: () => undefined,
readSessionMetadata: async () => metadata,
writeSessionMetadata: async (next) => {
metadata = next;
},
});
await runCheckpointHooks(hooks);
expect(readCheckpointSkips(metadata)).toEqual([
expect.objectContaining({ reason: "checkpoint_failed", runCount: 1 }),
]);
} finally {
await deleteCheckpointRefs(cwd, "sess_custom_skip");
await rm(cwd, { recursive: true, force: true });
}
});
it("emits one checkpoint.snapshot event per snapshot attempt without file paths", async () => {
const cwd = await createGitRepo();
const sessionId = "sess_telemetry";
+123 -12
View File
@@ -84,6 +84,21 @@ export interface CheckpointEntry {
kind?: "stash" | "commit";
}
/**
* Why a user turn ended without a workspace checkpoint. Persisted in session
* metadata so the missing-checkpoint explanation still names the cause after
* a window reload, instead of collapsing every failure into "no checkpoint".
* `createdAt` orders records; reasons carry a run count when the turn that
* skipped is known (custom checkpoint implementations may skip without one).
*/
export type CheckpointSkipReason = "no_git_repository" | "checkpoint_failed";
export interface CheckpointSkip {
reason: CheckpointSkipReason;
runCount?: number;
createdAt: number;
}
export interface CheckpointMetadata {
latest: CheckpointEntry;
history: CheckpointEntry[];
@@ -120,6 +135,45 @@ function warn(logger: BasicLogger | undefined, message: string): void {
logger?.log(message, { severity: "warn" });
}
/**
* Reads persisted checkpoint skips. Tolerant of malformed entries (the
* metadata is plain JSON that older builds also write): unknown reasons and
* non-positive run counts are dropped rather than failing the whole lookup.
*/
export function readCheckpointSkips(
metadata: Record<string, unknown> | undefined,
): CheckpointSkip[] {
const candidate = metadata?.checkpointSkips;
if (!Array.isArray(candidate)) {
return [];
}
return candidate.flatMap((entry): CheckpointSkip[] => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return [];
}
const record = entry as Partial<CheckpointSkip>;
if (
record.reason !== "no_git_repository" &&
record.reason !== "checkpoint_failed"
) {
return [];
}
const createdAt = Number(record.createdAt ?? 0);
if (!Number.isFinite(createdAt)) {
return [];
}
const runCount =
typeof record.runCount === "number" &&
Number.isInteger(record.runCount) &&
record.runCount >= 1
? record.runCount
: undefined;
return [
{ reason: record.reason, createdAt, ...(runCount ? { runCount } : {}) },
];
});
}
function readCheckpointMetadata(
metadata: Record<string, unknown> | undefined,
): CheckpointMetadata | undefined {
@@ -479,6 +533,22 @@ export async function retainCheckpointRefs(
);
}
function upsertCheckpointSkip(
skips: readonly CheckpointSkip[],
skip: CheckpointSkip,
): CheckpointSkip[] {
const existingIndex =
skip.runCount === undefined
? -1
: skips.findIndex((candidate) => candidate.runCount === skip.runCount);
if (existingIndex < 0) {
return [...skips, skip];
}
return skips.map((candidate, index) =>
index === existingIndex ? skip : candidate,
);
}
function upsertCheckpointHistory(
history: readonly CheckpointEntry[],
entry: CheckpointEntry,
@@ -534,17 +604,24 @@ export function createCheckpointHooks(
const createCheckpoint = async (
runCount: number,
): Promise<CheckpointEntry | undefined> => {
): Promise<
| { entry: CheckpointEntry }
| { skipReason: CheckpointSkipReason }
| undefined
> => {
if (options.createCheckpoint) {
return await options.createCheckpoint({
const entry = await options.createCheckpoint({
cwd: options.cwd,
sessionId: options.sessionId,
runCount,
});
// A custom implementation owns its outcome; record it as a failure
// rather than guessing (it may not be git-backed at all).
return entry ? { entry } : { skipReason: "checkpoint_failed" };
}
if (!(await ensureGitRepository())) {
return undefined;
return { skipReason: "no_git_repository" };
}
const startedAt = Date.now();
@@ -609,7 +686,9 @@ export function createCheckpointHooks(
"Checkpoint HEAD fallback failed",
);
captureSnapshot(fallback ? "head_fallback" : "skipped");
return fallback;
return fallback
? { entry: fallback }
: { skipReason: "checkpoint_failed" as const };
}
if (!ref) {
// A clean worktree with no untracked files — the HEAD entry is the
@@ -618,7 +697,9 @@ export function createCheckpointHooks(
"Checkpoint HEAD fallback failed",
);
captureSnapshot(fallback ? "head_clean" : "skipped");
return fallback;
return fallback
? { entry: fallback }
: { skipReason: "checkpoint_failed" as const };
}
// Store the stash commit under a private ref namespace so it is
@@ -636,15 +717,17 @@ export function createCheckpointHooks(
`Checkpoint store failed: ${error instanceof Error ? error.message : String(error)}`,
);
captureSnapshot("skipped");
return undefined;
return { skipReason: "checkpoint_failed" as const };
}
captureSnapshot("stash");
return {
ref,
createdAt: Date.now(),
runCount,
kind: "stash",
entry: {
ref,
createdAt: Date.now(),
runCount,
kind: "stash",
},
};
};
@@ -689,16 +772,44 @@ export function createCheckpointHooks(
if (!introducedUserRun && alreadyCheckpointed) {
return undefined;
}
const entry = await createCheckpoint(runCount);
if (!entry) {
const result = await createCheckpoint(runCount);
if (!result) {
return undefined;
}
if ("skipReason" in result) {
// Record why this turn has no checkpoint so the restore UI can
// name the cause later (including after a reload). Best effort:
// a failed metadata write must not fail the turn.
try {
await options.writeSessionMetadata({
...(metadata ?? {}),
checkpointSkips: upsertCheckpointSkip(
readCheckpointSkips(metadata),
{
reason: result.skipReason,
runCount,
createdAt: Date.now(),
},
),
});
} catch {
// The turn proceeds without a checkpoint either way.
}
return undefined;
}
const entry = result.entry;
// Preserve any recorded skips; a successful checkpoint for this run
// supersedes a skip recorded for the same run.
const checkpointSkips = readCheckpointSkips(metadata).filter(
(skip) => skip.runCount !== runCount,
);
if (existing?.latest.ref === entry.ref) {
return undefined;
}
const history = upsertCheckpointHistory(existing?.history ?? [], entry);
await options.writeSessionMetadata({
...(metadata ?? {}),
checkpointSkips,
checkpoint: {
latest: entry,
history,
+3
View File
@@ -460,7 +460,10 @@ export {
export type {
CheckpointEntry,
CheckpointMetadata,
CheckpointSkip,
CheckpointSkipReason,
} from "./hooks/checkpoint-hooks";
export { readCheckpointSkips } from "./hooks/checkpoint-hooks";
export * from "./hub";
export { HubRuntimeHost } from "./hub/runtime-host/hub-runtime-host";
export { RemoteRuntimeHost } from "./hub/runtime-host/remote-runtime-host";