Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 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
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,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 119 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:119: Survived BooleanLiteral mutant (replacement: false). See the job summary for the complete list and resolution guidance.

Check warning on line 119 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:119: 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 @@ -220,6 +227,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 @@ -352,6 +352,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
29 changes: 29 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,9 @@
public lastMessageTs?: number
private autoApprovalTimeoutRef?: NodeJS.Timeout

// B1: task-start baseline, recorded at most once (initiateTaskLoop also runs on resume).
private taskStartBaselineDone = false

// Tool Use
consecutiveMistakeCount: number = 0
consecutiveMistakeLimit: number
Expand Down Expand Up @@ -3705,6 +3708,24 @@
// arm needed.
void getCheckpointService(this)

// B1 task-start baseline: a suppressed pre-task root commit (default-on).
// History resumes skip it: the baseline belongs to the original task start
// (exactly once per task), and the resumed task's shadow repo already carries
// a baseline for the first post-resume write checkpoint to diff against.
// An already-aborted task skips it too: the loop will never run, so the
// save would only add latency to a task that is being disposed.
if (!this.taskStartBaselineDone && !this._isHistoryTask && !this.abort) {
this.taskStartBaselineDone = true
const baselineEnabled = (await this.providerRef.deref()?.getState())?.perWriteCheckpoints
// Cancellation can arrive while the state lookup is pending; recheck
// before the save so a canceled task performs no checkpoint work.
if (baselineEnabled !== false && !this.abort) {
// allowEmpty=true so a clean workspace still produces the baseline
// commit; awaited so the first per-write checkpoint cannot interleave.
await this.checkpointSave(true, true)
}
}

let nextUserContent = userContent
let includeFileDetails = true

Expand All @@ -3730,7 +3751,7 @@
// the user hits max requests and denies resetting the count.
break
} else {
nextUserContent = [{ type: "text", text: formatResponse.noToolsUsed() }]

Check warning on line 3754 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:3754: 2 mutation test gaps; example: Survived OptionalChaining mutant (replacement: (await this.providerRef.deref()?.getState()).perWriteCheckpoints). See the job summary for the complete list and resolution guidance.

Check warning on line 3754 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:3754: 2 mutation test gaps; example: Survived OptionalChaining mutant (replacement: (await this.providerRef.deref()?.getState()).perWriteCheckpoints). See the job summary for the complete list and resolution guidance.
}
}
}
Expand Down Expand Up @@ -6289,6 +6310,14 @@
* @param context - Context string for logging (e.g., the calling tool name)
*/
public processQueuedMessages(): void {
// An aborted task must not dequeue queued messages: submitUserMessage
// rejects once abort is set, so consuming the queue here would drop the
// message instead of leaving it for the resume/abort flow. Tools await
// checkpoint saves before handing off, so a cancellation can land between
// the write and this call.
if (this.abort) {
return
}
try {
if (!this.messageQueueService.isEmpty()) {
const queued = this.messageQueueService.dequeueMessage()
Expand Down
197 changes: 197 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6027,6 +6027,175 @@ describe("Cline", () => {
})
})

describe("task-start baseline (B1 perWriteCheckpoints)", () => {
it("records one suppressed baseline checkpoint per Task instance at loop start", async () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "baseline task",
startTask: false,
})
const taskAccess = getTaskTestAccess(task)
const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined)
const state = await mockProvider.getState()
vi.spyOn(mockProvider, "getState").mockResolvedValue(state)

// End the loop after the first request; abort stays false so the
// baseline block (not the loop) is what the save spy observes.
vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true)

await taskAccess.initiateTaskLoop([])
await taskAccess.initiateTaskLoop([])

expect(saveSpy).toHaveBeenCalledOnce()
expect(saveSpy).toHaveBeenCalledWith(true, true)
})

it("does not record a baseline when the task resumes from history", async () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
historyItem: {
id: "resumed-baseline-task",
number: 1,
ts: Date.now(),
task: "resumed baseline task",
tokensIn: 10,
tokensOut: 5,
totalCost: 0.001,
},
startTask: false,
})
const taskAccess = getTaskTestAccess(task)
const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined)
const state = await mockProvider.getState()
vi.spyOn(mockProvider, "getState").mockResolvedValue(state)

vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true)

await taskAccess.initiateTaskLoop([])

// A history resume must not re-record the baseline: it belongs to the
// original task start (exactly once per task).
expect(saveSpy).not.toHaveBeenCalled()
})

it("records the baseline checkpoint when the setting is unset (default-on)", async () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "baseline unset task",
startTask: false,
})
const taskAccess = getTaskTestAccess(task)
const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined)
const state = await mockProvider.getState()
// Unset: the property is absent from the state, so default-on applies.
const unsetState = { ...state }
Reflect.deleteProperty(unsetState, "perWriteCheckpoints")
vi.spyOn(mockProvider, "getState").mockResolvedValue(unsetState as typeof state)

vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true)

await taskAccess.initiateTaskLoop([])

expect(saveSpy).toHaveBeenCalledOnce()
expect(saveSpy).toHaveBeenCalledWith(true, true)
})

it("does not record a baseline checkpoint when perWriteCheckpoints is disabled", async () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "baseline disabled task",
startTask: false,
})
const taskAccess = getTaskTestAccess(task)
const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined)
const state = await mockProvider.getState()
vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...state, perWriteCheckpoints: false })

vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true)

await taskAccess.initiateTaskLoop([])

expect(saveSpy).not.toHaveBeenCalled()
})

it("does not record a baseline when the task is already aborted", async () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "aborted baseline task",
startTask: false,
})
const taskAccess = getTaskTestAccess(task)
const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined)
const state = await mockProvider.getState()
vi.spyOn(mockProvider, "getState").mockResolvedValue(state)

task.abort = true

await taskAccess.initiateTaskLoop([])

// A task cancelled before the loop starts must not pay for (or await)
// a baseline checkpoint it will never use.
expect(saveSpy).not.toHaveBeenCalled()
})

it("does not record a baseline when cancellation arrives during the state lookup", async () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "racy baseline task",
startTask: false,
})
const taskAccess = getTaskTestAccess(task)
const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined)
const state = await mockProvider.getState()
type StateResult = Awaited<ReturnType<typeof mockProvider.getState>>
let resolveState: (value: StateResult | PromiseLike<StateResult>) => void = () => {}
const statePromise = new Promise<StateResult>((resolve) => (resolveState = resolve))
vi.spyOn(mockProvider, "getState").mockReturnValue(statePromise)

const loopPromise = taskAccess.initiateTaskLoop([])
// Cancellation lands while the state lookup is still pending.
task.abort = true
resolveState({ ...state, perWriteCheckpoints: true })
await loopPromise

// The post-lookup recheck must skip the save for the canceled task.
expect(saveSpy).not.toHaveBeenCalled()
})

it("awaits the baseline checkpoint before entering the request loop", async () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "baseline await task",
startTask: false,
})
const taskAccess = getTaskTestAccess(task)
type SaveResult = Awaited<ReturnType<typeof task.checkpointSave>>
let resolveSave: (value: SaveResult | PromiseLike<SaveResult>) => void = () => {}
const saveSpy = vi
.spyOn(task, "checkpointSave")
.mockImplementation(() => new Promise<SaveResult>((resolve) => (resolveSave = resolve)))
const requestSpy = vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true)
vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...(await mockProvider.getState()) })
const loopPromise = taskAccess.initiateTaskLoop([])

// The loop must not enter while the baseline checkpoint is still in flight.
await new Promise((resolve) => setTimeout(resolve, 0))
expect(saveSpy).toHaveBeenCalledOnce()
expect(requestSpy).not.toHaveBeenCalled()

resolveSave()
await loopPromise
expect(requestSpy).toHaveBeenCalled()
})
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe("start()", () => {
it("should be a no-op if the task was already started in the constructor", () => {
const task = new Task({
Expand Down Expand Up @@ -6950,6 +7119,34 @@ describe("Queued message processing after condense", () => {
expect(submitSpy).toHaveBeenCalledWith("queued text", ["img1.png"])
expect(task.messageQueueService.isEmpty()).toBe(true)
})
it("does not dequeue queued messages once the task is aborted", () => {
// Tools await the checkpoint before handing off queued work; a
// cancellation landing during that await must not consume the queued
// message: submitUserMessage rejects once abort is set, so dequeuing
// would drop the input instead of leaving it for the resume flow.
if (!TelemetryService.hasInstance()) {
TelemetryService.createInstance([])
}
const provider = createProvider()
const task = new Task({
provider,
apiConfiguration: apiConfig,
task: "initial task",
startTask: false,
})
const submitSpy = vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined)

task.messageQueueService.addMessage("queued text")
task.abort = true
try {
task.processQueuedMessages()
} finally {
task.abort = false
}

expect(submitSpy).not.toHaveBeenCalled()
expect(task.messageQueueService.isEmpty()).toBe(false)
})

it("does not cross-drain queues between separate tasks", async () => {
const providerA = createProvider()
Expand Down
Loading
Loading