Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
d5f8a79
split unit U1 of PR 1833 (issue 1375)
Oct 5, 2026
aa0cdab
fix(file-safety): close the pre-merge findings on the publish primiti…
Oct 5, 2026
c4120b0
fix(file-safety): propagate a non-ENOENT lstat failure in resolvePubl…
Oct 5, 2026
97b599d
fix(file-safety): keep the rollback pair typed and the mock stand-ins…
Oct 5, 2026
435be8b
rebuild unit u2 on the fixed chain
Oct 5, 2026
625a976
chore(lint): prune the safeWriteJson suppression this unit earns
Oct 5, 2026
4e2de13
rebuild unit u3 on the fixed chain
Oct 5, 2026
60376ca
fix(task): declare the observation registry on Task in this unit
Oct 5, 2026
58a1f1c
test(task): pin the clock and restore real timers in the observation …
Oct 5, 2026
9e40ad3
test(utils): make the rollback-failure test name match what it asserts
Oct 5, 2026
1476213
fix(file-safety): inherit U1 committed-guard and exact rmdir assertion
Oct 5, 2026
761dec8
fix(file-safety): keep the publish error message in RollbackFailureError
Oct 5, 2026
e65efb0
test(file-safety): assert the exact staging directory removed after a…
Oct 5, 2026
5bfb81d
test: re-trigger required checks - the queued runs were cancelled by …
Oct 5, 2026
0d0ee7d
fix(file-safety): give the Windows DACL dump a per-write name
Oct 5, 2026
9c111ce
test(utils): assert the RollbackFailureError message, not only its fi…
Oct 5, 2026
d8b34b8
fix(file-safety): keep the target present by backing it up with a dur…
Oct 7, 2026
631bfd8
fix(file-safety): remove a partial backup when the backup copy or its…
Oct 7, 2026
10d2d97
fix(file-safety): report a failed DACL restore, and cover the primiti…
Oct 7, 2026
38f8a59
test(file-safety): drop the duplicated failed-commit case and cover t…
Oct 7, 2026
96b6024
fix(file-safety): remove the backup copy after a post-commit failure …
Oct 7, 2026
ee17f99
fix(file-safety): make the backup copy writable before fsyncing it
Oct 7, 2026
0574ad4
fix(file-safety): create the backup privately before its content exists
Oct 7, 2026
1b6ce40
feat(utils): let a caller confine a write to a directory
Oct 7, 2026
75fe4a4
fix(file-safety): do not read a failed target lstat as a missing target
Oct 7, 2026
ea5b3ce
fix(file-safety): compare staging and target identity with bigint stats
Oct 7, 2026
f74cac9
test(file-safety): pin the bigint options in the staging-identity tests
Oct 7, 2026
3486dc7
fix(file-safety): report a Windows replacement whose DACL was not pre…
Oct 7, 2026
fac2c7e
fix(file-safety): keep DACL warning delivery from failing the save
Oct 7, 2026
5f07a25
fix(utils): check confinement before taking the advisory lock
Oct 7, 2026
783d1dc
fix(file-safety): handle async warning sinks and confine before mkdir
Oct 7, 2026
256091d
fix(mcp): confine project MCP writes to the workspace root
Oct 8, 2026
ff6e864
test(utils): pin the fail-closed scope canonicalization in safeWriteJson
Oct 8, 2026
dd142c6
fix(file-safety): report a leftover backup copy instead of discarding…
Oct 8, 2026
799962b
fix(file-safety): report the exact orphan paths when post-failure cle…
Oct 8, 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
5 changes: 5 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import { buildNativeToolsArrayWithRestrictions } from "./build-tools"
import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector"
import { restoreTodoListForTask } from "../tools/UpdateTodoListTool"
import { FileContextTracker } from "../context-tracking/FileContextTracker"
import { ObservationRegistry } from "./observationRegistry"
import { RooIgnoreController } from "../ignore/RooIgnoreController"
import { RooProtectedController } from "../protect/RooProtectedController"
import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message"
Expand Down Expand Up @@ -286,6 +287,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
readonly instanceId: string
readonly metadata: TaskMetadata

// The observed on-disk version of each file this task has read. Declared here so the
// read tools can record it; a write guard later compares a token against this registry.
readonly observationRegistry = new ObservationRegistry()

todoList?: TodoItem[]

readonly rootTask: Task | undefined = undefined
Expand Down
24 changes: 24 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,30 @@ describe("Cline", () => {
expect(JSON.stringify(truncatedCallResult)).toContain("missing nativeArgs")
})
})
describe("observation registry is task-local (S3, epic #1375)", () => {
it("gives each Task its own observation registry", () => {
const firstTask = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "first observation task",
startTask: false,
})
const secondTask = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "second observation task",
startTask: false,
})

// The guarded-write contract assumes an observation in one task never validates
// a write issued by another task, so the registries must not be shared state.
expect(firstTask.observationRegistry).not.toBe(secondTask.observationRegistry)
firstTask.observationRegistry.observe("/workspace/a.ts", "v-a")
expect(firstTask.observationRegistry.get("/workspace/a.ts")?.version).toBe("v-a")
expect(secondTask.observationRegistry.get("/workspace/a.ts")).toBeUndefined()
})
})


describe("constructor", () => {
it.each([{ apiConfigName: "parent-local-profile" }, { apiConfigName: undefined }])(
Expand Down
120 changes: 120 additions & 0 deletions src/core/task/__tests__/observationRegistry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, it, expect, vi } from "vitest"

import { ObservationRegistry } from "../observationRegistry"

describe("ObservationRegistry", () => {
it("observe → get returns the recorded version and observedAt", () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"))
const reg = new ObservationRegistry()
reg.observe("/a/b/c.ts", "1:2:300:4000000000:5000000000")

const obs = reg.get("/a/b/c.ts")
expect(obs).toBeDefined()
expect(obs!.version).toBe("1:2:300:4000000000:5000000000")
// The clock is pinned, so this checks the recorded instant rather than
// merely that some number is present.
expect(obs!.observedAt).toBe(Date.parse("2026-01-01T00:00:00.000Z"))
} finally {
vi.useRealTimers()
}
})
Comment thread
easonLiangWorldedtech marked this conversation as resolved.

it("re-observe replaces the entry with a fresh observedAt", () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"))
const reg = new ObservationRegistry()
reg.observe("/a/b/c.ts", "v1")
const first = reg.get("/a/b/c.ts")!
expect(first.version).toBe("v1")

vi.advanceTimersByTime(50)
reg.observe("/a/b/c.ts", "v2")
const second = reg.get("/a/b/c.ts")!
expect(second.version).toBe("v2")
expect(second.observedAt).toBeGreaterThan(first.observedAt)
} finally {
// A failed assertion must not leave fake timers for the next test.
vi.useRealTimers()
}
})
Comment thread
easonLiangWorldedtech marked this conversation as resolved.

it("has returns true for observed paths, false otherwise", () => {
const reg = new ObservationRegistry()
reg.observe("/x.ts", "t1")
expect(reg.has("/x.ts")).toBe(true)
expect(reg.has("/y.ts")).toBe(false)
})

it("size reflects the number of observed entries", () => {
const reg = new ObservationRegistry()
expect(reg.size).toBe(0)
reg.observe("/a.ts", "t1")
reg.observe("/b.ts", "t2")
expect(reg.size).toBe(2)
})

it("clear removes all entries and resets size to 0", () => {
const reg = new ObservationRegistry()
reg.observe("/a.ts", "t1")
reg.observe("/b.ts", "t2")
reg.clear()
expect(reg.size).toBe(0)
expect(reg.get("/a.ts")).toBeUndefined()
expect(reg.has("/b.ts")).toBe(false)
})

it("get on empty registry returns undefined", () => {
const reg = new ObservationRegistry()
expect(reg.get("/any.ts")).toBeUndefined()
})

it("separate instances are independent — observing in one does not appear in the other", () => {
const regA = new ObservationRegistry()
const regB = new ObservationRegistry()
regA.observe("/shared.ts", "v1")
expect(regA.get("/shared.ts")).toBeDefined()
expect(regB.get("/shared.ts")).toBeUndefined()
regB.observe("/shared.ts", "v2")
expect(regA.get("/shared.ts")!.version).toBe("v1")
expect(regB.get("/shared.ts")!.version).toBe("v2")
})

describe("completeness scope (S4b follow-up #46)", () => {
it("defaults to a complete observation when the read scope is not given", () => {
const reg = new ObservationRegistry()
reg.observe("/a/b/c.ts", "v1")

expect(reg.get("/a/b/c.ts")!.complete).toBe(true)
})

it("records a partial observation when the read only returned a view of the file", () => {
const reg = new ObservationRegistry()
reg.observe("/a/b/c.ts", "v1", false)

expect(reg.get("/a/b/c.ts")!.complete).toBe(false)
})

it("re-observing replaces the entry's completeness with the new read's scope", () => {
const reg = new ObservationRegistry()
reg.observe("/a/b/c.ts", "v1", false)
reg.observe("/a/b/c.ts", "v2")

const obs = reg.get("/a/b/c.ts")!
expect(obs.version).toBe("v2")
expect(obs.complete).toBe(true)
})

it("re-observing with a partial scope downgrades a previously complete entry", () => {
const reg = new ObservationRegistry()
reg.observe("/a/b/c.ts", "v1")
reg.observe("/a/b/c.ts", "v2", false)

const obs = reg.get("/a/b/c.ts")!
expect(obs.version).toBe("v2")
expect(obs.complete).toBe(false)
})
})
})
59 changes: 59 additions & 0 deletions src/core/task/observationRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Per-task file observation registry (upstream epic #1375, phase A2).
*
* Each Task owns its own instance so parent and subtask observations are
* independent. The S4 guarded-write will compare these versions against the
* token recomputed pre-write to detect stale reads or file replacement.
*
* Pure in-memory — zero I/O, no dependencies. The S4 guarded-write consults
* these observations for the version check and for the completeness check that
* gates a full-file replacement.
*/

export interface FileObservation {
/** Version token derived from on-disk fs.stat (bigint mode). */
version: string
/** Millisecond timestamp when the observation was recorded. */
observedAt: number
/**
* Whether the read that produced this observation returned the complete
* file. A slice, line-range, truncated, or indentation-block read returns
* only a view of the file; such an observation authorizes targeted edits
* on the view the model saw, but never a full-file replacement.
*/
complete: boolean
}

export class ObservationRegistry {
private readonly entries = new Map<string, FileObservation>()

/**
* Record an observation for a file at its absolute path.
*
* Re-observing replaces the entry with a fresh observedAt timestamp, the
* new version token, and the read's completeness. `complete` defaults to
* true for callers that read the whole file themselves (spec doubles,
* WriteToFileTool). A caller whose read is internal to a targeted edit must
* carry the model's prior completeness instead, so the tool's own read cannot
* upgrade a partial read into authority for a full-file replacement.
*/
observe(absolutePath: string, version: string, complete: boolean = true): void {
this.entries.set(absolutePath, { version, observedAt: Date.now(), complete })
}

get(absolutePath: string): FileObservation | undefined {
return this.entries.get(absolutePath)
}

has(absolutePath: string): boolean {
return this.entries.has(absolutePath)
}

clear(): void {
this.entries.clear()
}

get size(): number {
return this.entries.size
}
}
2 changes: 1 addition & 1 deletion src/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -1716,7 +1716,7 @@
},
"utils/safeWriteJson.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 4
"count": 3
}
},
"utils/tts.ts": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"

import { safeWriteText } from "../safeWriteText"

// No fs mocks in this file: the point is to assert what a real filesystem ends up
// holding after a publish attempt, which the mocked spec cannot show. The failure is
// provoked with real filesystem semantics rather than with a stubbed call.
describe("safeWriteText against a real filesystem", () => {
let dir: string

beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-text-int-"))
})

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

it("publishes the new bytes and leaves no staging or backup residue", async () => {
const targetPath = path.join(dir, "target.txt")
await fs.writeFile(targetPath, "old bytes")

// No platform override: the real platform's own durability and ACL steps run.
// A failed icacls restore in a throwaway temp directory is reported, not thrown,
// so the publish still lands.
await safeWriteText(targetPath, "new bytes", { backup: true })

expect(await fs.readFile(targetPath, "utf8")).toBe("new bytes")
expect(await fs.readdir(dir)).toEqual(["target.txt"])
})

it("leaves the target bytes untouched when the backup copy of a directory target fails", async () => {
// A directory target makes the backup COPY fail first (a directory cannot be
// copied), so this covers the backup step, not the commit rename: the inner
// catch unlinks the partial backup and rethrows before the rename runs.
const targetPath = path.join(dir, "target-dir")
await fs.mkdir(targetPath)
const inside = path.join(targetPath, "payload.txt")
await fs.writeFile(inside, "original bytes")

await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow()

// The directory and its content are exactly as they were, and no backup copy
// or staging directory was left behind next to them.
expect(await fs.readFile(inside, "utf8")).toBe("original bytes")
expect(await fs.readdir(dir)).toEqual(["target-dir"])
})

it("leaves the target untouched when the commit rename itself cannot replace it", async () => {
// backup:false makes the commit rename the first operation that touches the
// target: a regular file cannot be renamed over a directory, so the failure
// under test is the commit, and the cleanup is the temp unlink plus the
// staging-directory removal.
const targetPath = path.join(dir, "target-dir")
await fs.mkdir(targetPath)
const inside = path.join(targetPath, "payload.txt")
await fs.writeFile(inside, "original bytes")

await expect(safeWriteText(targetPath, "new data", { backup: false })).rejects.toThrow()

// The directory and its content are exactly as they were, and neither the
// staged temp nor the staging directory was left behind.
expect(await fs.readFile(inside, "utf8")).toBe("original bytes")
expect(await fs.readdir(dir)).toEqual(["target-dir"])
})
})
Loading
Loading