feat: Make checkpoint on new task (#3834)

* feat: Make checkpoint on new task

Ensures that invoking the `newTaskTool` always creates a checkpoint, even if no files have changed. This provides a consistent state snapshot before a sub-task is initiated.

* refactor: remove delay

---------

Co-authored-by: Daniel <57051444+daniel-lxs@users.noreply.github.com>
This commit is contained in:
Sam Hoang Van 2025-05-29 00:13:42 +07:00 committed by GitHub
parent acd51c5120
commit e8b4dda922
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 192 additions and 7 deletions

View file

@ -152,7 +152,7 @@ async function getInitializedCheckpointService(
}
}
export async function checkpointSave(cline: Task) {
export async function checkpointSave(cline: Task, force = false) {
const service = getCheckpointService(cline)
if (!service) {
@ -169,7 +169,7 @@ export async function checkpointSave(cline: Task) {
telemetryService.captureCheckpointCreated(cline.taskId)
// Start the checkpoint process in the background.
return service.saveCheckpoint(`Task: ${cline.taskId}, Time: ${Date.now()}`).catch((err) => {
return service.saveCheckpoint(`Task: ${cline.taskId}, Time: ${Date.now()}`, { allowEmpty: force }).catch((err) => {
console.error("[Cline#checkpointSave] caught unexpected error, disabling checkpoints", err)
cline.enableCheckpoints = false
})

View file

@ -1736,8 +1736,8 @@ export class Task extends EventEmitter<ClineEvents> {
// Checkpoints
public async checkpointSave() {
return checkpointSave(this)
public async checkpointSave(force: boolean = false) {
return checkpointSave(this, force)
}
public async checkpointRestore(options: CheckpointRestoreOptions) {

View file

@ -69,6 +69,10 @@ export async function newTaskTool(
return
}
if (cline.enableCheckpoints) {
cline.checkpointSave(true)
}
// Preserve the current mode so we can resume with it later.
cline.pausedModeSlug = (await provider.getState()).mode ?? defaultModeSlug

View file

@ -214,9 +214,14 @@ export abstract class ShadowCheckpointService extends EventEmitter {
return this.shadowGitConfigWorktree
}
public async saveCheckpoint(message: string): Promise<CheckpointResult | undefined> {
public async saveCheckpoint(
message: string,
options?: { allowEmpty?: boolean },
): Promise<CheckpointResult | undefined> {
try {
this.log(`[${this.constructor.name}#saveCheckpoint] starting checkpoint save`)
this.log(
`[${this.constructor.name}#saveCheckpoint] starting checkpoint save (allowEmpty: ${options?.allowEmpty ?? false})`,
)
if (!this.git) {
throw new Error("Shadow git repo not initialized")
@ -224,7 +229,8 @@ export abstract class ShadowCheckpointService extends EventEmitter {
const startTime = Date.now()
await this.stageAll(this.git)
const result = await this.git.commit(message)
const commitArgs = options?.allowEmpty ? { "--allow-empty": null } : undefined
const result = await this.git.commit(message, commitArgs)
const isFirst = this._checkpoints.length === 0
const fromHash = this._checkpoints[this._checkpoints.length - 1] ?? this.baseHash!
const toHash = result.commit || fromHash

View file

@ -632,5 +632,180 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
expect(checkpointHandler).not.toHaveBeenCalled()
})
})
describe(`${klass.name}#saveCheckpoint with allowEmpty option`, () => {
it("creates checkpoint with allowEmpty=true even when no changes", async () => {
// No changes made, but force checkpoint creation
const result = await service.saveCheckpoint("Empty checkpoint", { allowEmpty: true })
expect(result).toBeDefined()
expect(result?.commit).toBeTruthy()
expect(typeof result?.commit).toBe("string")
})
it("does not create checkpoint with allowEmpty=false when no changes", async () => {
const result = await service.saveCheckpoint("No changes checkpoint", { allowEmpty: false })
expect(result).toBeUndefined()
})
it("does not create checkpoint by default when no changes", async () => {
const result = await service.saveCheckpoint("Default behavior checkpoint")
expect(result).toBeUndefined()
})
it("creates checkpoint with changes regardless of allowEmpty setting", async () => {
await fs.writeFile(testFile, "Modified content for allowEmpty test")
const resultWithAllowEmpty = await service.saveCheckpoint("With changes and allowEmpty", { allowEmpty: true })
expect(resultWithAllowEmpty?.commit).toBeTruthy()
await fs.writeFile(testFile, "Another modification for allowEmpty test")
const resultWithoutAllowEmpty = await service.saveCheckpoint("With changes, no allowEmpty")
expect(resultWithoutAllowEmpty?.commit).toBeTruthy()
})
it("emits checkpoint event for empty commits when allowEmpty=true", async () => {
const checkpointHandler = jest.fn()
service.on("checkpoint", checkpointHandler)
const result = await service.saveCheckpoint("Empty checkpoint event test", { allowEmpty: true })
expect(checkpointHandler).toHaveBeenCalledTimes(1)
const eventData = checkpointHandler.mock.calls[0][0]
expect(eventData.type).toBe("checkpoint")
expect(eventData.toHash).toBe(result?.commit)
expect(typeof eventData.duration).toBe("number")
expect(typeof eventData.isFirst).toBe("boolean") // Can be true or false depending on checkpoint history
})
it("does not emit checkpoint event when no changes and allowEmpty=false", async () => {
// First, create a checkpoint to ensure we're not in the initial state
await fs.writeFile(testFile, "Setup content")
await service.saveCheckpoint("Setup checkpoint")
// Reset the file to original state
await fs.writeFile(testFile, "Hello, world!")
await service.saveCheckpoint("Reset to original")
// Now test with no changes and allowEmpty=false
const checkpointHandler = jest.fn()
service.on("checkpoint", checkpointHandler)
const result = await service.saveCheckpoint("No changes, no event", { allowEmpty: false })
expect(result).toBeUndefined()
expect(checkpointHandler).not.toHaveBeenCalled()
})
it("handles multiple empty checkpoints correctly", async () => {
const commit1 = await service.saveCheckpoint("First empty checkpoint", { allowEmpty: true })
expect(commit1?.commit).toBeTruthy()
const commit2 = await service.saveCheckpoint("Second empty checkpoint", { allowEmpty: true })
expect(commit2?.commit).toBeTruthy()
// Commits should be different
expect(commit1?.commit).not.toBe(commit2?.commit)
})
it("logs correct message for allowEmpty option", async () => {
const logMessages: string[] = []
const testService = await klass.create({
taskId: "log-test",
shadowDir: path.join(tmpDir, `log-test-${Date.now()}`),
workspaceDir: service.workspaceDir,
log: (message: string) => logMessages.push(message),
})
await testService.initShadowGit()
await testService.saveCheckpoint("Test logging with allowEmpty", { allowEmpty: true })
const saveCheckpointLogs = logMessages.filter(msg =>
msg.includes("starting checkpoint save") && msg.includes("allowEmpty: true")
)
expect(saveCheckpointLogs).toHaveLength(1)
await testService.saveCheckpoint("Test logging without allowEmpty")
const defaultLogs = logMessages.filter(msg =>
msg.includes("starting checkpoint save") && msg.includes("allowEmpty: false")
)
expect(defaultLogs).toHaveLength(1)
})
it("maintains checkpoint history with empty commits", async () => {
// Create a regular checkpoint
await fs.writeFile(testFile, "Regular change")
const regularCommit = await service.saveCheckpoint("Regular checkpoint")
expect(regularCommit?.commit).toBeTruthy()
// Create an empty checkpoint
const emptyCommit = await service.saveCheckpoint("Empty checkpoint", { allowEmpty: true })
expect(emptyCommit?.commit).toBeTruthy()
// Create another regular checkpoint
await fs.writeFile(testFile, "Another regular change")
const anotherCommit = await service.saveCheckpoint("Another regular checkpoint")
expect(anotherCommit?.commit).toBeTruthy()
// Verify we can restore to the empty checkpoint
await service.restoreCheckpoint(emptyCommit!.commit)
expect(await fs.readFile(testFile, "utf-8")).toBe("Regular change")
// Verify we can restore to other checkpoints
await service.restoreCheckpoint(regularCommit!.commit)
expect(await fs.readFile(testFile, "utf-8")).toBe("Regular change")
await service.restoreCheckpoint(anotherCommit!.commit)
expect(await fs.readFile(testFile, "utf-8")).toBe("Another regular change")
})
it("handles getDiff correctly with empty commits", async () => {
// Create a regular checkpoint
await fs.writeFile(testFile, "Content before empty")
const beforeEmpty = await service.saveCheckpoint("Before empty")
expect(beforeEmpty?.commit).toBeTruthy()
// Create an empty checkpoint
const emptyCommit = await service.saveCheckpoint("Empty checkpoint", { allowEmpty: true })
expect(emptyCommit?.commit).toBeTruthy()
// Get diff between regular commit and empty commit
const diff = await service.getDiff({
from: beforeEmpty!.commit,
to: emptyCommit!.commit
})
// Should have no differences since empty commit doesn't change anything
expect(diff).toHaveLength(0)
})
it("works correctly in integration with new task workflow", async () => {
// Simulate the new task workflow where we force a checkpoint even with no changes
// This tests the specific use case mentioned in the git commit
// Start with a clean state (no pending changes)
const initialState = await service.saveCheckpoint("Check initial state")
expect(initialState).toBeUndefined() // No changes, so no commit
// Force a checkpoint for new task (this is the new functionality)
const newTaskCheckpoint = await service.saveCheckpoint("New task checkpoint", { allowEmpty: true })
expect(newTaskCheckpoint?.commit).toBeTruthy()
// Verify the checkpoint was created and can be restored
await fs.writeFile(testFile, "Work done in new task")
const workCommit = await service.saveCheckpoint("Work in new task")
expect(workCommit?.commit).toBeTruthy()
// Restore to the new task checkpoint
await service.restoreCheckpoint(newTaskCheckpoint!.commit)
// File should be back to original state
expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!")
})
})
},
)