Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 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
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.

/**
* 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 @@ -364,6 +364,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
28 changes: 28 additions & 0 deletions src/core/checkpoints/__tests__/checkpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,34 @@ 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("should wait for checkpoint service initialization before saving", async () => {
// Set up task with uninitialized service
mockCheckpointService.isInitialized = false
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)
})
})
51 changes: 44 additions & 7 deletions src/core/checkpoints/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@
const service = RepoPerTaskCheckpointService.create(options)
task.checkpointServiceInitializing = true
await checkGitInstallation(task, service, log, provider)
if (!service.isInitialized) {
// checkGitInstallation disables checkpoints when Git is unavailable and
// logs initShadowGit failures without throwing: storing such a service
// would hand later saves (and the fast path above) an uninitialized
// handle whose isInitialized can never flip, so a failed
// initialization must resolve to no service at all.
task.checkpointServiceInitializing = false
return undefined
}
task.checkpointService = service
if (task.enableCheckpoints) {
sendCheckpointInitWarn(task)
Expand Down Expand Up @@ -200,6 +209,10 @@
} catch (err) {
log(`[Task#getCheckpointService] initShadowGit -> ${err.message}`)
task.enableCheckpoints = false
// The "initialize" event never fires for a failed initShadowGit, so
// clear the flag here or later lookups wait on an initialization
// that already died.
task.checkpointServiceInitializing = false

Check warning on line 215 in src/core/checkpoints/index.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/checkpoints/index.ts:215: Survived BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.
}
} catch (err) {
log(`[Task#getCheckpointService] Unexpected error during Git check: ${err.message}`)
Expand All @@ -209,6 +222,29 @@
}
}

// Serialize checkpoint saves per task. saveCheckpoint() runs stageAll + commit, so two
// concurrent saves for the same task (for example the task-start baseline racing the first
// per-write save after the baseline timeout) can stage each other's writes or interleave
// commits. A per-task promise queue makes every save wait for the previous one, including
// a baseline that is still running.
const checkpointSaveQueues = new Map<string, Promise<unknown>>()

function enqueueCheckpointSave<T>(taskId: string, operation: () => Promise<T>): Promise<T> {
const previous = checkpointSaveQueues.get(taskId) ?? Promise.resolve()
const next = previous.then(operation, operation)
const tail = next.then(
() => undefined,
() => undefined,
)
checkpointSaveQueues.set(taskId, tail)
void tail.finally(() => {

Check warning on line 240 in src/core/checkpoints/index.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/checkpoints/index.ts:240: Survived BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
if (checkpointSaveQueues.get(taskId) === tail) {

Check warning on line 241 in src/core/checkpoints/index.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/checkpoints/index.ts:241: 4 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
checkpointSaveQueues.delete(taskId)

Check warning on line 242 in src/core/checkpoints/index.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/checkpoints/index.ts:242: Survived CallExpression mutant (replacement: ;). See the job summary for the complete list and resolution guidance.
}
})
return next
}

export async function checkpointSave(task: Task, force = false, suppressMessage = false) {
const service = await getCheckpointService(task)

Expand All @@ -218,13 +254,14 @@

TelemetryService.instance.captureCheckpointCreated(task.taskId)

// Start the checkpoint process in the background.
return service
.saveCheckpoint(`Task: ${task.taskId}, Time: ${Date.now()}`, { allowEmpty: force, suppressMessage })
.catch((err) => {
console.error("[Task#checkpointSave] caught unexpected error, disabling checkpoints", err)
task.enableCheckpoints = false
})
// Start the checkpoint process in the background, queued behind any save already
// running for this task so stageAll + commit never overlap.
return enqueueCheckpointSave(task.taskId, () =>
service.saveCheckpoint(`Task: ${task.taskId}, Time: ${Date.now()}`, { allowEmpty: force, suppressMessage }),
).catch((err) => {
console.error("[Task#checkpointSave] caught unexpected error, disabling checkpoints", err)

Check warning on line 262 in src/core/checkpoints/index.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/checkpoints/index.ts:262: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
task.enableCheckpoints = false
})
}

export type CheckpointRestoreOptions = {
Expand Down
Loading
Loading