Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
104 commits
Select commit Hold shift + click to select a range
a37dd24
feat(file-safety): atomic text publish primitive + safeWriteJson refa…
easonliang28 Aug 27, 2026
3dd8700
feat(file-safety): add version token for the guarded-write path (A1, …
easonliang28 Aug 27, 2026
ba1332e
docs(file-safety): correct ino precision bounds in version token (A1,…
easonliang28 Aug 27, 2026
6813013
fix(file-safety): derive the version token from exact BigInt stats (A…
easonliang28 Aug 27, 2026
588b95f
feat(task): per-task file observation registry (A2, #1375)
easonliang28 Aug 27, 2026
7a25fc0
feat(tools): guarded write CAS core with per-path FIFO chain (S4a, #1…
easonliang28 Aug 27, 2026
68be264
feat(tools): wire guarded writes into the diff-view save paths (S4b, …
easonliang28 Aug 27, 2026
88c9352
fix(fws): observe the apply_patch hunk read for the guarded publish
easonliang28 Aug 28, 2026
e96df62
chore(ci): empty commit — re-trigger CI and the CodeRabbit current-he…
easonliang28 Aug 30, 2026
aafdd1c
feat(tools): track read completeness and guard the diff-view save pat…
Sep 28, 2026
70ad366
test(tools): kill surviving mutants in the S4b completeness guards (S…
Sep 28, 2026
d8c5275
test(tools): pin the post-read stat options in the S4b preview tests …
Sep 28, 2026
786bd5c
fix(tools): clean up a partial DACL dump left by a failed icacls save…
Sep 28, 2026
7204ead
fix(tools): address CR review round 1 - partial-read gate, post-publi…
Sep 28, 2026
34fcfcd
test(tools): kill the 18 preflight survivors from the T1-T5 fix round…
Sep 28, 2026
56b329a
fix(tools): restructure the win32 DACL restore gate and close the las…
Sep 28, 2026
dd15018
test(tools): kill the last OptionalChaining mutant from the DACL rest…
Sep 28, 2026
a49fdb5
fix(tools): track the create-branch placeholder even over a prior obs…
Sep 28, 2026
d037753
test(tools): cover the create-branch task gate with a collected-task …
Sep 28, 2026
d8c34aa
fix(tools): stat-match the create placeholder + fail-closed save clea…
Sep 28, 2026
90d6804
Merge remote-tracking branch 'upstream/main' into feat/fws-s4b-followups
Oct 2, 2026
221eaed
chore: re-trigger CodeRabbit re-review and label reconcile
Oct 2, 2026
db5b56d
chore: trigger CodeRabbit re-review (finding already addressed)
Oct 2, 2026
c76f95b
Merge commit 'refs/shapes/upstream-main' into feat/fws-s4b-followups
Oct 2, 2026
174b825
chore: trigger CodeRabbit re-review / label reconcile
Oct 2, 2026
32c7afe
fix(s4b): give each self-staged write its own staging directory so a …
Oct 3, 2026
bc6f474
fix(s4b): remove the staging directory when a self-staged write fails
Oct 3, 2026
ad04d2b
test(s4b): pin the per-write staging directory name shape so the uniq…
Oct 3, 2026
bf27716
fix(s4b): publish in the document encoding, lock the resolved delete …
Oct 3, 2026
d1e4867
fix(s4b): pass the tool's write kind through the diff-view save and p…
Oct 3, 2026
133b985
test(s4b): assert the codec's bytes reach the publish, not only that …
Oct 3, 2026
31bd913
fix(s4b): keep a partial read partial after an edit and count a clipp…
Oct 3, 2026
1771fcc
fix(s4b): keep the model's read completeness for apply_patch and sepa…
Oct 3, 2026
b4ad010
fix(s4b): carry apply_patch completeness only on the version it was e…
Oct 3, 2026
a325bd2
fix(s4b): match the guard kind on the diff-view save and keep a clipp…
Oct 3, 2026
e018044
test(s4b): assert the saved result on the diff-view save paths
Oct 3, 2026
f223547
docs(s4b): bring the guard and registry headers in line with the ship…
Oct 3, 2026
abd1a07
docs(s4b): note the create exception in the guarded-write stale rule
Oct 3, 2026
2286f33
docs(s4b): name the token a later guarded write compares against
Oct 3, 2026
7f24fe7
test(s4b): make the diff-view save and fsync-ordering tests assert wh…
Oct 3, 2026
9297464
fix(s4b): do not save the document after the guarded publish
Oct 3, 2026
1b54313
fix(s4b): the diff-view preview is not a model read
Oct 3, 2026
04719af
fix(s4b): carry the source's completeness to a move destination
Oct 3, 2026
e765a4d
fix(s4b): finish the rejected-create cleanup, guard the revert, and k…
Oct 3, 2026
1bca6fc
fix(s4b): scope the workbench revert to the intended document
Oct 3, 2026
e0ce670
fix(s4b): only remove the placeholder when the discard completed
Oct 3, 2026
d113168
fix(s4b): probe the lock key the writer actually uses
Oct 3, 2026
6142333
fix(s4b): never publish through a dangling symlink
Oct 3, 2026
002513e
test(s4b): reset the focus the revert helper reads between tests
Oct 3, 2026
ef1bd10
fix(s4b): keep the liveness probe working for a dangling link
Oct 4, 2026
2e8598f
Merge upstream main (a077066538) into feat/fws-s4b-followups
Oct 4, 2026
52f5992
refactor(s4b): stay inside the changed-line cap
Oct 4, 2026
5eeaa39
fix(s4b): follow the whole link chain when probing the writer's lock
Oct 4, 2026
986bac2
chore: re-run CI after a flaky e2e-mock timeout
Oct 4, 2026
4b7fe7a
fix(s4b): bound the lock-key chain walk against a link cycle
Oct 4, 2026
2798f75
test(s4b): assert the cache result in the link-chain test
Oct 4, 2026
a8092db
fix(s4b): keep the delete paths and the read view honest
Oct 4, 2026
e724bcb
refactor(s4b): keep reconcile's JSDoc immediately above reconcile
Oct 4, 2026
d0a61fa
Re-trigger review at the current head
Oct 4, 2026
85b4a2b
Assert the unlink target in the delete() alias test
Oct 4, 2026
19e37b2
Make the link-cycle test actually run the cycle
Oct 4, 2026
891b18a
Adopt content that autosave already published instead of failing
Oct 4, 2026
0660afc
Cover the adoption branches the gate flagged
Oct 4, 2026
8e12e9f
Make the adoption check provably scoped to guard verdicts
Oct 4, 2026
1ea64ba
Merge branch 'main' of https://github.com/Zoo-Code-Org/Zoo-Code into …
Oct 4, 2026
ac71b93
Re-run the mutation gate on this PR's own delta
Oct 4, 2026
01918b6
Drop the three duplicated adoption tests
Oct 4, 2026
53c9b76
fix(file-safety): run the guard check and publish under the shared ad…
Oct 5, 2026
425aed9
test(file-safety): mock the shared advisory lock in specs that reach …
Oct 5, 2026
69bf534
fix(file-safety): key the advisory lock to the resolved target and re…
Oct 5, 2026
a55720e
test(file-safety): keep the lock-key spec portable and type-clean
Oct 5, 2026
e8d6e66
style(file-safety): format the lock-key spec
Oct 5, 2026
7624519
fix(file-safety): keep the resolution inside the protected block so t…
Oct 5, 2026
a5ec999
test(file-safety): exercise the ENOENT+symlink branch in the lock-key…
Oct 5, 2026
e14d72f
test(file-safety): assert the symlink branch the lock key depends on
Oct 5, 2026
206703d
test(file-safety): reach the strict rejection through the ENOENT+syml…
Oct 5, 2026
35d94be
fix(file-safety): canonicalize the lock key when the target itself is…
Oct 5, 2026
945460b
test(read-file): drop a no-op any cast and prune its suppression
Oct 5, 2026
b0a78f4
fix(editor): a rejected save closes only its own diff tab
Oct 5, 2026
b165482
test(task-persistence): canonicalize the directory in the realpath do…
Oct 5, 2026
eb6fc68
test(task-persistence): assert the lock key against the canonical dir…
Oct 5, 2026
f1a47df
test(task-persistence): make every realpath double canonicalize the d…
Oct 5, 2026
11afee7
fix(apply-patch): carry the source's completeness to the move destina…
Oct 5, 2026
ce61546
fix(file-safety): propagate a non-ENOENT stat failure in the mode-pre…
Oct 5, 2026
12525c5
fix(apply-patch): carry the source's completeness through the publish…
Oct 5, 2026
3704068
test(utils): clean up the lock-key spec's temp dirs and reset its dou…
Oct 5, 2026
9c42789
docs(test): put the canonicalization JSDoc on its function and cover …
Oct 5, 2026
98b0277
fix(apply-patch): reject a partial source move onto an observed desti…
Oct 5, 2026
08f0fc8
fix(apply-patch): reject a partial-source move only when the destinat…
Oct 5, 2026
303123b
fix(apply-patch): propagate a non-ENOENT destination access error bef…
Oct 5, 2026
9ba1e11
test(diff-view): cover a post-read stat rejection during the preview
Oct 5, 2026
5d7c726
fix(apply-patch): make queued guarded writes cancellation-aware
Oct 5, 2026
ded6d14
fix(diff-view): serialize the placeholder cleanup with the shared adv…
Oct 5, 2026
357e24e
fix(apply-patch): keep the model-facing guard path workspace-relative
Oct 5, 2026
bfb90a7
fix(apply-patch): name the caller's path on a stale-version rejection…
Oct 5, 2026
1b74c27
fix(diff-view): keep a reset inside the task whose save was rejected
Oct 5, 2026
25cf13b
fix(diff-view): identify this provider's tab by URI, not by basename
Oct 5, 2026
59b650f
test(apply-patch): put the stale-path test inside the describe block
Oct 5, 2026
2656fd2
fix(diff-view): close only Zoo's own diff tab for this provider
Oct 5, 2026
df793c8
fix(safe-write): do not log the expected miss in the safety-net cleanup
Oct 5, 2026
0ba7af0
fix(read-file): report clipping and truncation together
Oct 5, 2026
f6d460b
fix(diff-view): let one teardown path own a cancelled save
Oct 5, 2026
ddece31
fix(safe-write): report the partial state when a rollback also fails
Oct 5, 2026
6768ccf
fix(apply-patch): recheck cancellation before publication starts
Oct 5, 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
23 changes: 20 additions & 3 deletions src/core/task-persistence/TaskHistoryStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { historyItemSchema, type HistoryItem } from "@roo-code/types"
import { GlobalFileNames } from "../../shared/globalFileNames"
import { LOCK_STALE_MS, withFileLock } from "../../utils/fileLock"
import { safeWriteJson } from "../../utils/safeWriteJson"
import { resolveLockKey } from "../../services/file-safety/safeWriteText"
import { getStorageBasePath } from "../../utils/storage"
import { assertValidTransition, settleRejectedCreateSubtaskAction, type HistoryItemStatus } from "./taskLifecycle"
import { computeHistoryDelta, DeltaRejectedError, mergeHistoryDelta } from "./taskStoreConcurrency"
Expand Down Expand Up @@ -280,7 +281,10 @@ export class TaskHistoryStore {
// Remove per-task file (best-effort)
try {
const filePath = await this.getTaskFilePath(taskId)
await withFileLock(filePath, (absoluteFilePath) => fs.unlink(absoluteFilePath))
// Lock the resolved publish target, not the path as spelled: proper-lockfile
// keys the lock by the path it is given, so an alias and its referent would
// take two locks for one file. The unlink still removes the named path.
await withFileLock(await this.lockKeyFor(filePath), () => fs.unlink(filePath))
} catch {
// File may already be deleted
}
Expand Down Expand Up @@ -308,7 +312,9 @@ export class TaskHistoryStore {
// Remove per-task file (best-effort)
try {
const filePath = await this.getTaskFilePath(taskId)
await withFileLock(filePath, (absoluteFilePath) => fs.unlink(absoluteFilePath))
// Same lock key as delete(): the resolved referent, while the unlink
// still removes the path the caller named.
await withFileLock(await this.lockKeyFor(filePath), () => fs.unlink(filePath))
} catch {
// File may already be deleted
}
Expand All @@ -323,6 +329,15 @@ export class TaskHistoryStore {

// ────────────────────────────── Reconciliation ──────────────────────────────

/**
* The lock key a writer would use for a task file. resolvePublishTarget refuses
* a dangling link, so the delete paths and the liveness probe walk the chain
* themselves to find the lock held at the referent.
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
private async lockKeyFor(taskFilePath: string): Promise<string> {
return resolveLockKey(taskFilePath)
}

/**
* Scan task directories and fix any drift between disk and cache.
*
Expand Down Expand Up @@ -375,7 +390,9 @@ export class TaskHistoryStore {
// held for the entire write, so its presence means a
// write is in progress — keep the task live.
try {
const lockPath = (await this.getTaskFilePath(taskId)) + ".lock"
// Probe the same key the writer locks: safeWriteJson locks the resolved
// publish target, so an alias and its referent share one lock file.
const lockPath = (await this.lockKeyFor(await this.getTaskFilePath(taskId))) + ".lock"
const lockStat = await fs.stat(lockPath)
if (Date.now() - lockStat.mtimeMs < LOCK_STALE_MS) {
liveIds.add(taskId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ function historyFilePath(storagePath: string, taskId: string): string {
return path.join(storagePath, "tasks", taskId, GlobalFileNames.historyItem)
}

// The lock key is canonicalized through the parent directory, so the expected key is
// the canonical directory plus the basename rather than the path built from
// os.tmpdir(), which can be an 8.3 short path on the Windows runner.
async function canonicalKey(filePath: string): Promise<string> {
return path.join(await actualFs.realpath(path.dirname(filePath)), path.basename(filePath))
}

function storeInternals(store: TaskHistoryStore): {
cache: Map<string, HistoryItem>
taskFileMtimes: Map<string, number>
Expand All @@ -71,6 +78,7 @@ describe("TaskHistoryStore best-effort deletion semantics", () => {
storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-delete-semantics-"))
stores = []
onWrite = vi.fn().mockResolvedValue(undefined)
vi.mocked(withFileLock).mockClear()
vi.mocked(withFileLock).mockImplementation(actualFileLock.withFileLock)
vi.mocked(fs.unlink).mockImplementation(actualFs.unlink)
})
Expand All @@ -97,12 +105,15 @@ describe("TaskHistoryStore best-effort deletion semantics", () => {
await store.upsert(makeHistoryItem({ id: "locked-delete" }))
onWrite.mockClear()

// The lock key is the resolved publish target, so the expected key is
// captured through realpath before the delete: on Windows os.tmpdir()
// can be an 8.3 short path (C:\Users\RUNNER~1) that realpath expands
// to the long form, and the file is gone after the unlink.
const lockKey = await fs.realpath(historyFilePath(storagePath, "locked-delete"))

await expect(store.delete("locked-delete")).resolves.toBeUndefined()

expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(
historyFilePath(storagePath, "locked-delete"),
expect.any(Function),
)
expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(lockKey, expect.any(Function))
await expect(fs.access(historyFilePath(storagePath, "locked-delete"))).rejects.toMatchObject({
code: "ENOENT",
})
Expand Down Expand Up @@ -173,6 +184,248 @@ describe("TaskHistoryStore best-effort deletion semantics", () => {
expect(store.get("never-existed")).toBeUndefined()
expect(onWrite).toHaveBeenCalledTimes(1)
})

it("locks the resolved publish target while unlinking the path it was given", async () => {
const store = createStore()
await store.initialize()
await store.upsert(makeHistoryItem({ id: "alias-del" }))
const aliasPath = historyFilePath(storagePath, "alias-del")
const referentPath = path.join(storagePath, "tasks", "alias-del", "referent-history.json")

// Real symlinks are unavailable in this CI lane, so the alias is
// simulated through realpath, as in the safeWriteJson lock test.
// Only the file resolves through the alias; the directory is canonicalized by the
// the real fs exactly as in production, so the key matches the canonical directory
const realpathSpy = vi
.spyOn(fs, "realpath")
.mockImplementation(async (target) =>
target === aliasPath ? referentPath : actualFs.realpath(String(target)),
)
try {
await expect(store.delete("alias-del")).resolves.toBeUndefined()
} finally {
realpathSpy.mockRestore()
}

// One lock for the underlying file, keyed by the referent; the unlink
// still targets the path the store named.
expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(await canonicalKey(referentPath), expect.any(Function))
// Assert the unlink target itself: locking the referent while unlinking the
// referent instead of the alias would keep the dangling link in place.
expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(aliasPath)
expect(vi.mocked(fs.unlink)).not.toHaveBeenCalledWith(referentPath)
})

it("waits on the peer's lock at the referent when the link is dangling", async () => {
// delete() must resolve the chain itself: resolvePublishTarget refuses a
// dangling link, and the old code treated that rejection as "already deleted"
// so the link was never removed.
const store = createStore()
await store.initialize()
await store.upsert(makeHistoryItem({ id: "alias-dangling" }))
const aliasPath = historyFilePath(storagePath, "alias-dangling")
const referentPath = path.join(storagePath, "tasks", "alias-dangling", "referent-history.json")
// Only the file resolves through the alias; the directory is canonicalized by the
// the real fs exactly as in production, so the key matches the canonical directory
const enoent = Object.assign(new Error("ENOENT"), { code: "ENOENT" })
const realpathSpy = vi.spyOn(fs, "realpath").mockImplementation(async (target) => {
if (target === aliasPath) throw enoent
return actualFs.realpath(String(target))
})
const lstatSpy = vi
.spyOn(fs, "lstat")
.mockResolvedValue({ isSymbolicLink: () => true } as unknown as import("fs").Stats)
const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async (p) => {
if (p === aliasPath) return "referent-history.json"
throw new Error("not a symbolic link")
})
try {
await expect(store.delete("alias-dangling")).resolves.toBeUndefined()
} finally {
realpathSpy.mockRestore()
lstatSpy.mockRestore()
readlinkSpy.mockRestore()
}

expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(await canonicalKey(referentPath), expect.any(Function))
expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(aliasPath)
})
})

describe("reconcile()", () => {
it("keeps a cached task live when its file is absent but the lock is held at the resolved referent", async () => {
const store = createStore()
await store.initialize()
await store.upsert(makeHistoryItem({ id: "alias-live" }))
const aliasPath = historyFilePath(storagePath, "alias-live")
const referentPath = path.join(storagePath, "tasks", "alias-live", "referent-history.json")

// Real symlinks are unavailable in this CI lane, so the alias is
// simulated through realpath, as in the safeWriteJson lock test.
// Only the file resolves through the alias; the directory is canonicalized by the
// the real fs exactly as in production, so the key matches the canonical directory
const realpathSpy = vi
.spyOn(fs, "realpath")
.mockImplementation(async (target) =>
target === aliasPath ? referentPath : actualFs.realpath(String(target)),
)
try {
// The rename window: the referent is momentarily missing, so the cached
// file cannot be stat'd while the peer holds the lock at the key it locks.
await actualFs.rm(aliasPath, { force: true })
await actualFs.writeFile(referentPath + ".lock", "")
await store.reconcile()
} finally {
realpathSpy.mockRestore()
}

// The probe must look at the same key the writer locks, otherwise a live
// task is evicted from the cache while its write is still in progress.
expect(storeInternals(store).cache.has("alias-live")).toBe(true)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("keeps a cached task live when the alias is dangling during the rename window", async () => {
// resolvePublishTarget refuses a dangling link because a writer must not publish
// through the link path, but this probe runs exactly in that window, so it reads
// the link one level itself to find the lock the writer holds at the referent.
const store = createStore()
await store.initialize()
await store.upsert(makeHistoryItem({ id: "dangling-live" }))
const aliasPath = historyFilePath(storagePath, "dangling-live")
const referentPath = path.join(storagePath, "tasks", "dangling-live", "referent-history.json")

const realpathSpy = vi
.spyOn(fs, "realpath")
.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }))
const lstatSpy = vi
.spyOn(fs, "lstat")
.mockResolvedValue({ isSymbolicLink: () => true } as unknown as import("fs").Stats)
// A relative link target, so the probe must resolve it against the link's
// directory rather than the process working directory.
const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async (p) => {
if (p === aliasPath) return "referent-history.json"
throw new Error("not a symbolic link")
})
try {
await actualFs.rm(aliasPath, { force: true })
await actualFs.writeFile(referentPath + ".lock", "")
await store.reconcile()
} finally {
realpathSpy.mockRestore()
lstatSpy.mockRestore()
readlinkSpy.mockRestore()
}

expect(storeInternals(store).cache.has("dangling-live")).toBe(true)
})

it("follows the whole link chain to the key the writer locked", async () => {
// realpath resolves the whole chain, so it fails when the final referent is
// momentarily renamed to its backup. The probe must walk the chain, not just
// its first link, or it looks for a lock the writer never took.
const store = createStore()
await store.initialize()
await store.upsert(makeHistoryItem({ id: "chain-live" }))
const aliasPath = historyFilePath(storagePath, "chain-live")
const dir = path.dirname(aliasPath)
const referentPath = path.join(dir, "referent-history.json")
const realpathSpy = vi
.spyOn(fs, "realpath")
.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }))
const lstatSpy = vi
.spyOn(fs, "lstat")
.mockResolvedValue({ isSymbolicLink: () => true } as unknown as import("fs").Stats)
const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async (p) => {
if (p === aliasPath) return "nested-link.json"
if (p === path.join(dir, "nested-link.json")) return "referent-history.json"
throw new Error("not a symbolic link")
})
try {
await actualFs.rm(aliasPath, { force: true })
await actualFs.writeFile(referentPath + ".lock", "")
await store.reconcile()
} finally {
realpathSpy.mockRestore()
lstatSpy.mockRestore()
readlinkSpy.mockRestore()
}

expect(storeInternals(store).cache.has("chain-live")).toBe(true)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("probes the key the bounded walk actually reached on a long chain", async () => {
// A chain longer than the hop limit: the walk stops at the limit, so the probe
// must look at the key it reached, not at the far end of the chain.
const store = createStore()
await store.initialize()
await store.upsert(makeHistoryItem({ id: "long-chain" }))
const startPath = historyFilePath(storagePath, "long-chain")
const dir = path.dirname(startPath)
const hops = Array.from({ length: 9 }, (_, index) => path.join(dir, "h" + (index + 1) + ".json"))
const reachedPath = hops[7]
const realpathSpy = vi
.spyOn(fs, "realpath")
.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }))
const lstatSpy = vi
.spyOn(fs, "lstat")
.mockResolvedValue({ isSymbolicLink: () => true } as unknown as import("fs").Stats)
const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async (p) => {
const index = [startPath, ...hops].indexOf(String(p))
if (index === -1 || index === hops.length) throw new Error("not a symbolic link")
return hops[index]
})
try {
await actualFs.rm(startPath, { force: true })
await actualFs.writeFile(reachedPath + ".lock", "")
await store.reconcile()
} finally {
realpathSpy.mockRestore()
lstatSpy.mockRestore()
readlinkSpy.mockRestore()
}

expect(storeInternals(store).cache.has("long-chain")).toBe(true)
})

it("terminates on a link cycle instead of holding the store lock forever", async () => {
// Two links that point at each other: every readlink succeeds, so an unbounded
// walk would never release the store lock.
const store = createStore()
await store.initialize()
await store.upsert(makeHistoryItem({ id: "cycle-live" }))
const aPath = historyFilePath(storagePath, "cycle-live")
const bPath = path.join(path.dirname(aPath), "b.json")
const realpathSpy = vi
.spyOn(fs, "realpath")
.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }))
const lstatSpy = vi
.spyOn(fs, "lstat")
.mockResolvedValue({ isSymbolicLink: () => true } as unknown as import("fs").Stats)
const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async (p) => {
if (p === aPath) return "b.json"
// Point back to the real alias basename so the walk actually loops; returning
// a name that is not the alias stops the walk after two hops and never reaches
// the hop limit this test is about.
if (p === bPath) return path.basename(aPath)
throw new Error("not a symbolic link")
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try {
await actualFs.rm(aPath, { force: true })
await store.reconcile()
// Assert inside the try: mockRestore() clears the call counts, so checking after
// the finally block would read zero. Eight hops means the walk looped on the
// cycle instead of stopping at the first non-link.
expect(readlinkSpy).toHaveBeenCalledTimes(8)
} finally {
realpathSpy.mockRestore()
lstatSpy.mockRestore()
readlinkSpy.mockRestore()
}

// The walk is bounded, so reconcile returned; the probe looked at the key it
// reached after the bounded hops, found no lock there, and evicted the task.
expect(storeInternals(store).cache.has("cycle-live")).toBe(false)
})
})

describe("deleteMany()", () => {
Expand Down Expand Up @@ -239,5 +492,29 @@ describe("TaskHistoryStore best-effort deletion semantics", () => {
const writtenIds = (onWrite.mock.calls[0][0] as HistoryItem[]).map((item) => item.id)
expect(writtenIds).toEqual([])
})

it("locks the resolved publish target for every item while unlinking the given paths", async () => {
const store = createStore()
await store.initialize()
await store.upsert(makeHistoryItem({ id: "alias-batch", ts: 1000 }))
const aliasPath = historyFilePath(storagePath, "alias-batch")
const referentPath = path.join(storagePath, "tasks", "alias-batch", "referent-history.json")

// Only the file resolves through the alias; the directory is canonicalized by the
// the real fs exactly as in production, so the key matches the canonical directory
const realpathSpy = vi
.spyOn(fs, "realpath")
.mockImplementation(async (target) =>
target === aliasPath ? referentPath : actualFs.realpath(String(target)),
)
try {
await expect(store.deleteMany(["alias-batch"])).resolves.toBeUndefined()
} finally {
realpathSpy.mockRestore()
}

expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(await canonicalKey(referentPath), expect.any(Function))
expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(aliasPath)
})
})
})
2 changes: 2 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector"
import { restoreTodoListForTask } from "../tools/UpdateTodoListTool"
import { FileContextTracker } from "../context-tracking/FileContextTracker"
import { RooIgnoreController } from "../ignore/RooIgnoreController"
import { ObservationRegistry } from "./observationRegistry"
import { RooProtectedController } from "../protect/RooProtectedController"
import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message"
import { NativeToolCallParser } from "../assistant-message/NativeToolCallParser"
Expand Down Expand Up @@ -292,6 +293,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
readonly parentTask: Task | undefined = undefined
readonly taskNumber: number
readonly workspacePath: string
readonly observationRegistry = new ObservationRegistry()

/**
* The mode associated with this task. Persisted across sessions
Expand Down
Loading
Loading