Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
93a8329
feat(checkpoints): per-task change journal with torn-tail repair (B2,…
easonliang28 Aug 27, 2026
7b83143
chore(ci): empty commit — re-trigger CI and the CodeRabbit current-he…
easonliang28 Aug 30, 2026
a5764dd
fix(checkpoints): address CodeRabbit review feedback (PR 1406)
easonliang28 Aug 30, 2026
69485e4
Merge remote-tracking branch 'upstream/main' into feat/change-journal-b2
Sep 28, 2026
6654bff
test(visual): regenerate electron chat-dark sidebar reference for #1406
Sep 29, 2026
1d2373d
Merge upstream/main (9c069e94ef) into feat/change-journal-b2
easonliang28 Oct 12, 2026
86f4182
style(checkpoints): prettier-format files flagged by the new format:c…
easonliang28 Oct 12, 2026
ba5908b
fix(checkpoints): skip task-start baseline on history resume
easonliang28 Oct 12, 2026
6503823
fix(checkpoints): skip baseline on aborted task; share default constant
easonliang28 Oct 12, 2026
e443eb8
fix(checkpoints): recheck cancellation after the baseline state lookup
easonliang28 Oct 12, 2026
4c0bc15
fix(checkpoints): await write-path checkpoint saves before state handoff
easonliang28 Oct 12, 2026
7b85cf2
fix(task): never dequeue queued messages for an aborted task
easonliang28 Oct 13, 2026
82639cc
chore: remove carried-over Stryker suppression directives from touche…
easonliang28 Oct 14, 2026
502d3b7
Merge remote-tracking branch 'upstream/main' into feat/change-journal-b2
easonLiangWorldedtech Oct 14, 2026
a3fb016
fix: satisfy unused-variable lint after main refresh
easonliang28 Oct 14, 2026
8d9a0e2
fix: validate the runtime shape of parsed journal lines
easonliang28 Oct 15, 2026
f1faf66
Merge remote-tracking branch 'upstream/main' into feat/change-journal-b2
easonLiangWorldedtech Oct 15, 2026
a3cde76
Merge upstream/main (tool-call state scoping) and adapt execute spec …
easonliang28 Oct 15, 2026
59fc275
style: apply Prettier formatting to callback fixture edits
easonliang28 Oct 15, 2026
02849ce
docs: align journal contract and spec block name with partial-flush b…
easonliang28 Oct 15, 2026
afe14ad
fix: harden change-journal append and task-ID path containment
easonliang28 Oct 15, 2026
182f4b0
fix: propagate non-ENOENT tail-inspection errors and complete partial…
easonliang28 Oct 15, 2026
25fe557
Merge upstream/main into b2 (upstream refresh; e2e screenshot conflic…
easonliang28 Oct 17, 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 @@ -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.

/**
* 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 @@ -363,6 +363,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
233 changes: 233 additions & 0 deletions src/core/checkpoints/__tests__/changeJournal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import fs from "fs/promises"
import os from "os"
import path from "path"

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"

import { appendChange, journalPath, loadChanges, type ChangeJournalEntry } from "../changeJournal"

describe("changeJournal", () => {
const taskId = "test-task"

let tmpRoot: string

beforeEach(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "b2-journal-"))
})

afterEach(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true })
})

function entry(overrides: Partial<ChangeJournalEntry> = {}): ChangeJournalEntry {
return {
path: "src/foo.ts",
operation: "create",
checkpointId: "abc12345",
...overrides,
}
}

describe("appendChange", () => {
it("writes one JSON line per entry with the documented field shape", async () => {
await appendChange(tmpRoot, taskId, entry({ operation: "create", checkpointId: "aaa" }))

const raw = await fs.readFile(journalPath(tmpRoot, taskId), "utf8")
const lines = raw.split("\n").filter((line) => line !== "")
expect(lines).toHaveLength(1)
const parsed = JSON.parse(lines[0]) as ChangeJournalEntry
expect(parsed.path).toBe("src/foo.ts")
expect(parsed.operation).toBe("create")
expect(parsed.checkpointId).toBe("aaa")
})

it("appends multiple entries sequentially", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "a" }))
await appendChange(tmpRoot, taskId, entry({ checkpointId: "b" }))

const raw = await fs.readFile(journalPath(tmpRoot, taskId), "utf8")
expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(2)
})

it("repairs a torn tail before appending so both entries load", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "good" }))
// Simulate a crash that tore the last append mid-write: the file now
// ends with an unterminated JSON fragment.
const filePath = journalPath(tmpRoot, taskId)
const raw = await fs.readFile(filePath, "utf8")
await fs.writeFile(filePath, raw + '{"path":"src/half.ts","operation":"upd')

await appendChange(tmpRoot, taskId, entry({ checkpointId: "after" }))

const result = await loadChanges(tmpRoot, taskId)
expect(result.map((e) => e.checkpointId)).toEqual(["good", "after"])
})

it("does not insert a blank line when the journal ends cleanly", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "a" }))
await appendChange(tmpRoot, taskId, entry({ checkpointId: "b" }))

const raw = await fs.readFile(journalPath(tmpRoot, taskId), "utf8")
expect(raw).not.toContain("\n\n")
expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(2)
})
it("propagates a non-ENOENT tail-inspection failure instead of appending blind", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "good" }))
const filePath = journalPath(tmpRoot, taskId)
const raw = await fs.readFile(filePath, "utf8")
await fs.writeFile(filePath, raw + '{"path":"src/half.ts","operation":"upd')
// The tail cannot be inspected (I/O error): appending would merge the
// new entry into the fragment, so appendChange must fail loudly.
const statSpy = vi
.spyOn(fs, "stat")
.mockRejectedValueOnce(Object.assign(new Error("simulated I/O error"), { code: "EIO" }))
try {
await expect(appendChange(tmpRoot, taskId, entry({ checkpointId: "after" }))).rejects.toMatchObject({
code: "EIO",
})
} finally {
statSpy.mockRestore()
}
// The failed append wrote nothing: the next append still repairs and loads.
await appendChange(tmpRoot, taskId, entry({ checkpointId: "after" }))
const result = await loadChanges(tmpRoot, taskId)
expect(result.map((e) => e.checkpointId)).toEqual(["good", "after"])
})

it("writes the full record when the underlying write completes partially", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "first" }))
const realOpen = fs.open
const openSpy = vi.spyOn(fs, "open").mockImplementation(async (pathish, flags) => {
const openAs = realOpen as (p: string | URL, f?: string) => Promise<fs.FileHandle>
const handle = await openAs(pathish as string | URL, flags as string | undefined)
if (flags === "a") {
const realWrite = handle.write.bind(handle)
let first = true
handle.write = (async (buf: Uint8Array, off?: number, len?: number) => {
const length = len ?? buf.length
if (first && length > 1) {
first = false
await realWrite(buf, off, 1)
return { bytesWritten: 1, buffer: Buffer.from(buf) }
}
return realWrite(buf, off, length)
}) as typeof handle.write
}
return handle
})
try {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "second" }))
} finally {
openSpy.mockRestore()
}
const result = await loadChanges(tmpRoot, taskId)
expect(result.map((e) => e.checkpointId)).toEqual(["first", "second"])
})
})

describe("task ID containment", () => {
it("rejects task IDs that could escape the tasks directory", async () => {
for (const bad of ["../../outside", "a/b", "a\\b", "", ".", ".."]) {
expect(() => journalPath(tmpRoot, bad)).toThrow(/Unsafe task ID/)
await expect(appendChange(tmpRoot, bad, entry({ checkpointId: "x" }))).rejects.toThrow(/Unsafe task ID/)
await expect(loadChanges(tmpRoot, bad)).rejects.toThrow(/Unsafe task ID/)
// Nothing was created outside the task directory.
await expect(fs.stat(path.join(tmpRoot, "..", "outside"))).rejects.toMatchObject({ code: "ENOENT" })
}
})

it("keeps a safe task ID under the tasks directory", () => {
expect(journalPath(tmpRoot, "task-42")).toBe(
path.join(tmpRoot, "tasks", "task-42", "checkpoints", "changes.jsonl"),
)
})
})

describe("loadChanges", () => {
it("returns [] for an absent journal file", async () => {
expect(await loadChanges(tmpRoot, taskId)).toEqual([])
})

it("returns [] for an empty journal file", async () => {
await fs.mkdir(path.dirname(journalPath(tmpRoot, taskId)), { recursive: true })
await fs.writeFile(journalPath(tmpRoot, taskId), "")

expect(await loadChanges(tmpRoot, taskId)).toEqual([])
})

it("parses all entries in order with a clean tail", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "x" }))
await appendChange(tmpRoot, taskId, entry({ checkpointId: "y" }))
await appendChange(tmpRoot, taskId, entry({ checkpointId: "z" }))

const result = await loadChanges(tmpRoot, taskId)
expect(result).toHaveLength(3)
expect(result[0].checkpointId).toBe("x")
expect(result[1].checkpointId).toBe("y")
expect(result[2].checkpointId).toBe("z")
})

it("parses a journal whose final line has no trailing newline", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" }))

// Rewrite the file without the trailing newline of the last line.
const filePath = journalPath(tmpRoot, taskId)
const content = (await fs.readFile(filePath, "utf8")).replace(/\n$/, "")
await fs.writeFile(filePath, content)

const result = await loadChanges(tmpRoot, taskId)
expect(result).toHaveLength(1)
expect(result[0].checkpointId).toBe("ok")
})

it("discards a torn final line and returns the complete entries", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" }))

// Append a second line truncated mid-content, with no trailing newline.
await fs.appendFile(journalPath(tmpRoot, taskId), '{"path":"src/half.ts","operation":"upd')

const result = await loadChanges(tmpRoot, taskId)
expect(result).toHaveLength(1)
expect(result[0].checkpointId).toBe("ok")
})

it("skips a corrupt middle line and still loads the later valid entries", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" }))

// Corrupt the first line in place, then append a valid entry after it.
await fs.writeFile(
journalPath(tmpRoot, taskId),
'"{"path":"src/corrupt.ts","operation":"update"\n' +
JSON.stringify(entry({ checkpointId: "after" })) +
"\n",
)

const result = await loadChanges(tmpRoot, taskId)
expect(result).toHaveLength(1)
expect(result[0].checkpointId).toBe("after")
})

it("does not throw when the entire journal is torn", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "first" }))

// Truncate to a single character — definitely invalid JSON.
await fs.writeFile(journalPath(tmpRoot, taskId), "{")

expect(await loadChanges(tmpRoot, taskId)).toEqual([])
})

it("includes diffStats when present", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "s", diffStats: { additions: 5, deletions: 2 } }))

const result = await loadChanges(tmpRoot, taskId)
expect(result[0].diffStats).toEqual({ additions: 5, deletions: 2 })
})

it("omits diffStats when not provided", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "n" }))

const result = await loadChanges(tmpRoot, taskId)
expect(result[0].diffStats).toBeUndefined()
})
})
})
Loading
Loading