Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
abfbe7f
feat(checkpoints): per-write checkpoints, task-start baseline, and pe…
easonliang28 Aug 27, 2026
b98f159
fix(fws): export the webview initializer and test the pre-hydration c…
easonliang28 Aug 28, 2026
abc10b4
chore(ci): empty commit — re-trigger CI and the CodeRabbit current-he…
easonliang28 Aug 30, 2026
0ea153d
Merge commit '0d937c050810ebc305b34368bf6d6ae8c1f89966' into feat/per…
easonliang28 Sep 4, 2026
cb657cb
Merge remote-tracking branch 'upstream/main' into feat/per-write-chec…
easonliang28 Sep 14, 2026
92ceb90
Merge remote-tracking branch 'upstream/main' into feat/per-write-chec…
Sep 28, 2026
23e9451
test(visual): regenerate electron chat-dark sidebar reference for #1404
Sep 29, 2026
b01a334
Merge upstream/main (9c069e94ef) into feat/per-write-checkpoints-b1
easonliang28 Oct 11, 2026
c07bea3
fix(checkpoints): skip task-start baseline on history resume
easonliang28 Oct 12, 2026
93e3640
fix(checkpoints): skip baseline on aborted task; share default constant
easonliang28 Oct 12, 2026
faf3f9d
fix(checkpoints): recheck cancellation after the baseline state lookup
easonliang28 Oct 12, 2026
fb57670
fix(checkpoints): await write-path checkpoint saves before state handoff
easonliang28 Oct 12, 2026
cae09fb
test(checkpoints): pin one-checkpoint-per-patch for multi-file patches
easonliang28 Oct 12, 2026
c6a23c5
fix(tools): defer the queued-message handoff until the patch checkpoi…
easonliang28 Oct 13, 2026
484d5cf
fix(task): never dequeue queued messages for an aborted task
easonliang28 Oct 13, 2026
0310c99
test(tools): pin the partial-failure handoff semantics of the patch c…
easonliang28 Oct 13, 2026
a83a105
chore: remove carried-over Stryker suppression directives from touche…
easonliang28 Oct 14, 2026
d21a437
test: strengthen the early-return handoff assertions
easonliang28 Oct 15, 2026
dd4802d
Merge upstream/main (tool-call state scoping) and adapt execute spec …
easonliang28 Oct 15, 2026
6666de5
style: apply Prettier formatting to callback fixture edits
easonliang28 Oct 15, 2026
3391528
test: assert the in-place update checkpoint contract
easonliang28 Oct 15, 2026
6f00e59
fix(apply-patch): fail moves when source deletion fails; checkpoint p…
easonliang28 Oct 16, 2026
e6e69fc
Merge upstream/main into b1 (upstream refresh; e2e screenshot conflic…
easonliang28 Oct 17, 2026
20af1c7
fix(tools): checkpoint retained writes before handoff after a handler…
easonliang28 Oct 17, 2026
a0d292e
fix(task): task-start checkpoint baseline errors no longer abort task…
easonliang28 Oct 17, 2026
8045ab0
Merge remote-tracking branch 'upstream/main' into feat/per-write-chec…
easonliang28 Oct 17, 2026
8865c4a
fix(checkpoints): restore a clobbered move destination when source de…
easonliang28 Oct 18, 2026
911ae24
fix(checkpoints): harden task-start baseline and service init per review
easonliang28 Oct 18, 2026
377c082
fix(task): bound the task-start baseline save; report a failed move r…
Oct 21, 2026
da46574
Merge remote-tracking branch 'upstream/main' into feat/per-write-chec…
easonLiangWorldedtech Oct 22, 2026
b6d8698
fix(tools): make the apply_patch move transactional with atomic stagi…
easonLiangWorldedtech Oct 22, 2026
4b44f10
fix(tools): preserve destination identity and permissions in the atom…
easonLiangWorldedtech Oct 22, 2026
5a32d91
fix(checkpoints): serialize per-task checkpoint saves
easonLiangWorldedtech Oct 22, 2026
2e66db5
fix(tools): keep staging owner-only and make partial patch mutations …
easonLiangWorldedtech Oct 22, 2026
c585b1f
style: apply prettier formatting to the staging and mutation-tracking…
easonLiangWorldedtech Oct 22, 2026
c6bcc62
fix(tools): track committed mutations outside the handler-return boun…
easonLiangWorldedtech Oct 22, 2026
e2417cb
chore: re-trigger CI after the e2e restart-phase timeout
easonLiangWorldedtech Oct 22, 2026
fc11d6d
Merge upstream/main (3ee042d91) into feat/per-write-checkpoints-b1
easonLiangWorldedtech Nov 3, 2026
7f9ebb4
test+fix(checkpoints): correct the delete-ordering mock, kill the han…
easonLiangWorldedtech Nov 8, 2026
8415547
fix(per-write-checkpoints): lock restores, track retained writes, nam…
easonLiangWorldedtech Nov 9, 2026
d437d69
test(task): raise the abort latch through requestAbort in the three c…
easonLiangWorldedtech Nov 9, 2026
48be786
Merge upstream/main (e487cd7b2a) into feat/per-write-checkpoints-b1
easonLiangWorldedtech Nov 9, 2026
e406c09
Fix the two checkpoint-queue findings the review raised, and stop a s…
easonLiangWorldedtech Nov 9, 2026
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
13 changes: 13 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@
*/
export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15

/**
* Whether per-write checkpoints and task-start baseline are enabled by default.
* Master switch for the B cluster of checkpoint features.
* @default true
*/
export const DEFAULT_PER_WRITE_CHECKPOINTS = true

Check warning on line 122 in packages/types/src/global-settings.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

packages/types/src/global-settings.ts:122: Survived BooleanLiteral mutant (replacement: false). See the job summary for the complete list and resolution guidance.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/**
* GlobalSettings
*/
Expand Down Expand Up @@ -223,6 +230,12 @@
.min(MIN_CHECKPOINT_TIMEOUT_SECONDS)
.max(MAX_CHECKPOINT_TIMEOUT_SECONDS)
.optional(),
/**
* Whether to record a shadow-git checkpoint after every successful write_to_file,
* edit_file, and apply_patch (per-write checkpoints), plus a task-start baseline.
* @default true
*/
perWriteCheckpoints: z.boolean().optional(),

ttsEnabled: z.boolean().optional(),
ttsSpeed: z.number().optional(),
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ export type ExtensionState = Pick<

enableCheckpoints: boolean
checkpointTimeout: number // Timeout for checkpoint initialization in seconds (default: 15)
perWriteCheckpoints: boolean
maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500)
maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500)
showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings
Expand Down
204 changes: 204 additions & 0 deletions src/core/checkpoints/__tests__/checkpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,179 @@ describe("Checkpoint functionality", () => {
})

describe("checkpointSave", () => {
it("serializes concurrent saves for the same task so stageAll + commit never overlap", async () => {
const order: string[] = []
let resolveFirst: (value: { commit: string }) => void = () => {}
mockCheckpointService.saveCheckpoint
.mockImplementationOnce(() => {
order.push("first:start")
return new Promise((resolve) => {
resolveFirst = resolve
})
})
.mockImplementationOnce(async () => {
order.push("second:start")
return { commit: "second-commit" }
})

const first = checkpointSave(mockTask, false, true)
const second = checkpointSave(mockTask, false, true)

// The second save must not reach the service while the first is still running:
// a queued save owns the stageAll + commit pair until it settles.
await new Promise((resolve) => setTimeout(resolve, 10))
expect(order).toEqual(["first:start"])

resolveFirst({ commit: "first-commit" })
await Promise.all([first, second])
expect(order).toEqual(["first:start", "second:start"])
})

it("drops a queued save whose checkpoint window expired behind a stalled predecessor", async () => {
// A stalled baseline releases the task after checkpointTimeout, but the queue used to
// start it anyway, however late, committing whatever the workspace had become by then.
// It must not: the save is dropped and the caller resolves with no commit.
mockTask.checkpointTimeout = 0.01
const order: string[] = []
let resolveFirst: (value: { commit: string }) => void = () => {}
mockCheckpointService.saveCheckpoint
.mockImplementationOnce(() => {
order.push("stalled:start")
return new Promise((resolve) => {
resolveFirst = resolve
})
})
.mockImplementation(async () => {
order.push("late:start")
return { commit: "late-commit" }
})
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})

const stalled = checkpointSave(mockTask, true, true)
const queued = await (async () => {
await new Promise((resolve) => setTimeout(resolve, 5))
const second = checkpointSave(mockTask, false, true)
// Give the expired request its turn behind the still-running baseline, then release
// the baseline so the queue drains.
await new Promise((resolve) => setTimeout(resolve, 30))
resolveFirst({ commit: "stalled-commit" })
return second
})()

const second = await queued
await stalled
// Only the stalled baseline ever reached the shadow git; the expired save never started,
// so no commit describes a workspace that has since moved on.
expect(order).toEqual(["stalled:start"])
expect(mockCheckpointService.saveCheckpoint).toHaveBeenCalledTimes(1)
expect(second).toBeUndefined()
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("skipping queued per-write checkpoint save"))
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("expired behind a slower checkpoint"))
warnSpy.mockRestore()
})

it("drops a queued save for a task that was canceled while it waited", async () => {
mockTask.checkpointTimeout = 15
const order: string[] = []
let resolveFirst: (value: { commit: string }) => void = () => {}
mockCheckpointService.saveCheckpoint
.mockImplementationOnce(() => {
order.push("stalled:start")
return new Promise((resolve) => {
resolveFirst = resolve
})
})
.mockImplementation(async () => {
order.push("canceled:start")
return { commit: "canceled-commit" }
})
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
mockTask.abort = false

const stalled = checkpointSave(mockTask, true, true)
const queued = checkpointSave(mockTask, false, true)
await new Promise((resolve) => setTimeout(resolve, 5))
// Cancellation arrives while the save is queued, before its turn.
mockTask.abort = true
resolveFirst({ commit: "stalled-commit" })

const second = await queued
await stalled

expect(order).toEqual(["stalled:start"])
expect(mockCheckpointService.saveCheckpoint).toHaveBeenCalledTimes(1)
expect(second).toBeUndefined()
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("task canceled"))
warnSpy.mockRestore()
mockTask.abort = false
})

it("runs a save requested after a stalled predecessor settles, exactly once each", async () => {
// The gate must not poison the queue: the dropped request stays dropped, while a fresh
// request after the stall settles gets its normal turn.
mockTask.checkpointTimeout = 0.01
const order: string[] = []
let resolveFirst: (value: { commit: string }) => void = () => {}
mockCheckpointService.saveCheckpoint
.mockImplementationOnce(() => {
order.push("stalled:start")
return new Promise((resolve) => {
resolveFirst = resolve
})
})
.mockImplementation(async () => {
order.push("fresh:start")
return { commit: "fresh-commit" }
})
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})

const stalled = checkpointSave(mockTask, true, true)
// Requested while its window is still open, so the drop below is about the wait behind
// the stalled baseline rather than about an already-expired request.
const expired = checkpointSave(mockTask, false, true)
await new Promise((resolve) => setTimeout(resolve, 30))
resolveFirst({ commit: "stalled-commit" })
const expiredResult = await expired
const settled = await stalled
const fresh = await checkpointSave(mockTask, false, true)

expect(order).toEqual(["stalled:start", "fresh:start"])
expect(mockCheckpointService.saveCheckpoint).toHaveBeenCalledTimes(2)
// The dropped request resolves exactly the way a missing service does: no commit value,
// while the predecessor that held the queue keeps its own result.
expect(expiredResult).toBeUndefined()
expect(settled).toEqual({ commit: "stalled-commit" })
expect(fresh).toEqual({ commit: "fresh-commit" })
warnSpy.mockRestore()
})

it("never drops a user-initiated restore queued behind a stalled save", async () => {
// Restores carry no expiry: dropping one would silently discard an explicit user action.
mockTask.checkpointTimeout = 0.01
mockTask.clineMessages = [{ ts: 2, type: "say", say: "checkpoint_saved", text: RESTORE_HASH }]
const order: string[] = []
let resolveSave: (value: { commit: string }) => void = () => {}
mockCheckpointService.saveCheckpoint.mockImplementationOnce(() => {
order.push("save:start")
return new Promise((resolve) => {
resolveSave = resolve
})
})
mockCheckpointService.restoreCheckpoint.mockImplementationOnce(() => {
order.push("restore:start")
return Promise.resolve(undefined)
})

const stalled = checkpointSave(mockTask, true, true)
const restore = checkpointRestore(mockTask, { ts: 2, commitHash: RESTORE_HASH, mode: "preview" })
await new Promise((resolve) => setTimeout(resolve, 30))
resolveSave({ commit: "stalled-commit" })
await Promise.all([stalled, restore])

expect(order).toEqual(["save:start", "restore:start"])
expect(mockCheckpointService.restoreCheckpoint).toHaveBeenCalledWith(RESTORE_HASH)
})

it("should wait for checkpoint service initialization before saving", async () => {
// Set up task with uninitialized service
mockCheckpointService.isInitialized = false
Expand Down Expand Up @@ -280,6 +453,37 @@ describe("Checkpoint functionality", () => {
expect(mockCheckpointService.restoreCheckpoint).not.toHaveBeenCalled()
})

it("serializes a restore against a queued save so no save stages over a restore", async () => {
const order: string[] = []
let resolveRestore: () => void = () => {}
mockCheckpointService.restoreCheckpoint.mockImplementationOnce(
() =>
new Promise((resolve) => {
order.push("restore:start")
resolveRestore = resolve as () => void
}),
)
mockCheckpointService.saveCheckpoint.mockImplementationOnce(async () => {
order.push("save:start")
return { commit: "queued-commit" }
})

const restore = checkpointRestore(mockTask, { ts: 2, commitHash: RESTORE_HASH, mode: "preview" })
// Let the restore reach the shadow-git reset.
await new Promise((resolve) => setTimeout(resolve, 10))

const save = checkpointSave(mockTask, false, true)
// The save must not reach stageAll + commit while the restore is resetting the
// workspace: that commit would record the restored bytes and drop the write's own
// checkpoint.
await new Promise((resolve) => setTimeout(resolve, 10))
expect(order).toEqual(["restore:start"])

resolveRestore()
await Promise.all([restore, save])
expect(order).toEqual(["restore:start", "save:start"])
})

it.each(["delete", "edit"] as const)(
"propagates restore failures for %s without rewinding",
async (operation) => {
Expand Down
126 changes: 126 additions & 0 deletions src/core/checkpoints/__tests__/getCheckpointService.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it, vi } from "vitest"

import type { Task } from "../../task/Task"
import { RepoPerTaskCheckpointService } from "../../../services/checkpoints"
import { checkGitInstalled } from "../../../utils/git"
import { getCheckpointService } from "../index"

vi.mock("../../../services/checkpoints", () => ({
RepoPerTaskCheckpointService: { create: vi.fn() },
}))

vi.mock("../../../utils/git", () => ({
checkGitInstalled: vi.fn(),
}))

vi.mock("../../../utils/path", () => ({
getWorkspacePath: vi.fn(() => "/workspace"),
}))

vi.mock("../../../i18n", () => ({
t: (key: string) => key,
}))

/**
* Structural service double: getCheckpointService only registers listeners,
* awaits initShadowGit, and reads isInitialized. The double assertion at the
* create() call sites is confined to this file (AGENTS.md last-resort cast):
* the real class carries a Git handle and event surface the test never
* exercises.
*/
function serviceDouble(initShadowGitImpl: () => Promise<void>) {
const listeners = new Map<string, (...args: unknown[]) => void>()
let initialized = false
const svc = {
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
listeners.set(event, cb)
}),
initShadowGit: vi.fn(() => initShadowGitImpl()),
get isInitialized() {
return initialized
},
completeInit: () => {
initialized = true
listeners.get("initialize")?.()
},
}
return svc
}

function makeTask(): Task {
return {
taskId: "task-init",
cwd: "/workspace",
enableCheckpoints: true,
checkpointService: undefined,
checkpointServiceInitializing: false,
checkpointTimeout: 1,
providerRef: {
deref: vi.fn().mockReturnValue({
context: { globalStorageUri: { fsPath: "/tmp/b3-init-storage" } },
postMessageToWebview: vi.fn(),
log: vi.fn(),
}),
},
say: vi.fn().mockResolvedValue(undefined),
} as unknown as Task
}

describe("getCheckpointService initialization failures", () => {
beforeEach(() => {
vi.mocked(checkGitInstalled).mockReset()
vi.mocked(RepoPerTaskCheckpointService.create).mockReset()
})

it("does not store a service whose shadow-git initialization failed", async () => {
vi.mocked(checkGitInstalled).mockResolvedValue(true)
const failing = serviceDouble(async () => {
throw new Error("shadow git init exploded")
})
vi.mocked(RepoPerTaskCheckpointService.create).mockReturnValue(
failing as unknown as RepoPerTaskCheckpointService,
)

const task = makeTask()
const service = await getCheckpointService(task)

// The failed service must not be stored: a stored uninitialized handle
// would be returned by the fast path to every later save, and the
// initializing flag must not strand later lookups in a wait for an
// initialization that already died.
expect(service).toBeUndefined()
expect(task.checkpointService).toBeUndefined()
expect(task.checkpointServiceInitializing).toBe(false)
expect(task.enableCheckpoints).toBe(false)
})

it("does not store a service when Git is not installed", async () => {
vi.mocked(checkGitInstalled).mockResolvedValue(false)
const svc = serviceDouble(async () => {})
vi.mocked(RepoPerTaskCheckpointService.create).mockReturnValue(svc as unknown as RepoPerTaskCheckpointService)

const task = makeTask()
const service = await getCheckpointService(task)

expect(service).toBeUndefined()
expect(task.checkpointService).toBeUndefined()
expect(task.checkpointServiceInitializing).toBe(false)
expect(task.enableCheckpoints).toBe(false)
})

it("stores an initialized service on the success path", async () => {
vi.mocked(checkGitInstalled).mockResolvedValue(true)
const svc = serviceDouble(async () => {
svc.completeInit()
})
vi.mocked(RepoPerTaskCheckpointService.create).mockReturnValue(svc as unknown as RepoPerTaskCheckpointService)

const task = makeTask()
const service = await getCheckpointService(task)

expect(service).toBe(svc)
expect(task.checkpointService).toBe(svc)
expect(task.checkpointServiceInitializing).toBe(false)
expect(task.enableCheckpoints).toBe(true)
})
})
Loading
Loading