Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
131d18d
feat(file-safety): add version token for the guarded-write path (A1, …
easonliang28 Aug 27, 2026
13188d2
docs(file-safety): correct ino precision bounds in version token (A1,…
easonliang28 Aug 27, 2026
2c1582b
fix(file-safety): derive the version token from exact BigInt stats (A…
easonliang28 Aug 27, 2026
2965ad1
feat(task): per-task file observation registry (A2, #1375)
easonliang28 Aug 27, 2026
a00eef8
chore(ci): empty commit — re-trigger CI and the CodeRabbit current-he…
easonliang28 Aug 30, 2026
05c845d
Merge commit '0d937c050810ebc305b34368bf6d6ae8c1f89966' into feat/obs…
easonliang28 Sep 4, 2026
30f5d8c
Merge remote-tracking branch 'upstream/main' into feat/observation-re…
easonliang28 Sep 14, 2026
988f1e2
test(registry): restore fake timers in teardown and cover token-looku…
easonliang28 Sep 14, 2026
6b821b2
test(registry): assert read content in the token-failure regression t…
easonliang28 Sep 23, 2026
5a4e27e
Merge branch 'main' into feat/observation-registry-s2
easonliang28 Sep 23, 2026
770f21b
chore(ci): empty commit — trigger the CodeRabbit review at head (chat…
Oct 5, 2026
bcefb66
Merge upstream/main (9af61f87e) to satisfy the branch-freshness thres…
easonLiangWorldedtech Oct 6, 2026
0cb754c
fix(observation): drop a stale observation when a later token lookup …
Oct 6, 2026
932b2f1
chore(merge): refresh onto main 4f1dee18e
easonLiangWorldedtech Oct 16, 2026
addf98b
style(tools): drop imports unused after the main refresh (lint gate)
easonLiangWorldedtech Oct 16, 2026
a4d9f01
test(core): drop a stale observation when the version token is unreso…
easonLiangWorldedtech Nov 4, 2026
9dc3b15
test(core): cover observation-registry privacy across a parent and su…
easonLiangWorldedtech Nov 4, 2026
9fe680c
Merge upstream/main into feat/observation-registry-s2
easonLiangWorldedtech Nov 5, 2026
dd060a4
fix(tools): take the read version token before the read, and pin the …
easonLiangWorldedtech Nov 5, 2026
57b98c9
test(tools): pin the legacy token order and fold the stat double
easonLiangWorldedtech Nov 7, 2026
323d2d8
test(tools): pin what a rejecting context tracker leaves behind
easonLiangWorldedtech Nov 7, 2026
afe6024
refactor(task): drop the registry accessors no production code calls
easonLiangWorldedtech Nov 7, 2026
06658a8
Merge upstream/main 73931fe into feat/observation-registry-s2
easonLiangWorldedtech Nov 7, 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
2 changes: 2 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,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 { DispatchState, type PendingWork } from "./dispatchState"
Expand Down Expand Up @@ -302,6 +303,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
40 changes: 40 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1761,6 +1761,46 @@ describe("Cline", () => {
new Task({ provider: mockProvider, apiConfiguration: mockApiConfig })
}).toThrow("Either historyItem or task/images must be provided")
})

it("keeps the observation registry private to each task in a parent/subtask pair", () => {
// The write guard compares an observed token against the file on disk, so a
// subtask must neither inherit nor overwrite what the parent observed: each
// Task builds its own registry rather than borrowing the parent's.
const parent = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "parent task",
startTask: false,
})
const child = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "child task",
parentTask: parent,
rootTask: parent,
startTask: false,
})

// The constructor records the parent object plus the root/parent ids; the
// rootTask object field is filled in by callers (ClineProvider), not by the
// constructor, so the id link is what this boundary can assert.
expect(child.parentTask).toBe(parent)
expect(child.parentTaskId).toBe(parent.taskId)
expect(child.rootTaskId).toBe(parent.taskId)
expect(child.observationRegistry).not.toBe(parent.observationRegistry)

// The same path recorded in both tasks, with different tokens.
parent.observationRegistry.observe("/workspace/shared.ts", "1:2:3:4:5")
child.observationRegistry.observe("/workspace/shared.ts", "9:8:7:6:5")

expect(parent.observationRegistry.get("/workspace/shared.ts")?.version).toBe("1:2:3:4:5")
expect(child.observationRegistry.get("/workspace/shared.ts")?.version).toBe("9:8:7:6:5")

// Neither direction leaks: dropping the child's entry leaves the parent's.
child.observationRegistry.forget("/workspace/shared.ts")
expect(child.observationRegistry.get("/workspace/shared.ts")).toBeUndefined()
expect(parent.observationRegistry.get("/workspace/shared.ts")?.version).toBe("1:2:3:4:5")
})
})

describe("updateApiConfiguration", () => {
Expand Down
72 changes: 72 additions & 0 deletions src/core/task/__tests__/observationRegistry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect, vi } from "vitest"

import { ObservationRegistry } from "../observationRegistry"

describe("ObservationRegistry", () => {
it("observe → get returns the recorded version and the clock time it observed at", () => {
// typeof number accepts 0 and NaN, so pin the exact timestamp instead: a
// broken clock that returns some other number must not pass.
vi.useFakeTimers()
vi.setSystemTime(new Date(Date.UTC(2025, 0, 1, 0, 0, 0, 0)))
try {
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")
expect(obs!.observedAt).toBe(Date.UTC(2025, 0, 1, 0, 0, 0, 0))
} finally {
vi.useRealTimers()
}
})

it("re-observe replaces the entry with a fresh observedAt", () => {
vi.useFakeTimers()
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
Resolved
try {
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 {
// Teardown in finally so a failing assertion cannot leak fake
// timers or the mocked Date into subsequent tests.
vi.useRealTimers()
}
})

it("forget drops a warm entry so a later read cannot be guarded by a stale token", () => {
const reg = new ObservationRegistry()
reg.observe("/x.ts", "t1")

// The warm case: an entry from an earlier read must not survive a read that could not
// compute a token for the current on-disk version.
expect(reg.forget("/x.ts")).toBe(true)
expect(reg.get("/x.ts")).toBeUndefined()

// The cold case: forgetting an unobserved path is a no-op, not an error.
expect(reg.forget("/y.ts")).toBe(false)
expect(reg.get("/y.ts")).toBeUndefined()
})

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")
})
})
49 changes: 49 additions & 0 deletions src/core/task/observationRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* 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. Nothing in this PR reads an
* observation back out; the guard that will compare these tokens is a later phase.
*
* Only observe, forget and get are exposed: the S4 guard is the only named consumer. A
* per-task instance needs no reset - the next task constructs a new registry - so has, size
* and clear land with whoever needs them (a registry shared across tasks would need the
* reset, and that is not decided yet).
*/

export interface FileObservation {
/** Version token derived from on-disk fs.stat (bigint mode). */
version: string
/** Millisecond timestamp when the observation was recorded. */
observedAt: number
}

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 and
* the new version token.
*/
observe(absolutePath: string, version: string): void {
this.entries.set(absolutePath, { version, observedAt: Date.now() })
}

/**
* Drop the observation for a file. A read that cannot compute a version token must not
* leave an earlier entry behind, because the write guard would then compare against a
* token that no longer describes the last read.
*/
forget(absolutePath: string): boolean {
return this.entries.delete(absolutePath)
}

get(absolutePath: string): FileObservation | undefined {
return this.entries.get(absolutePath)
}
}
32 changes: 32 additions & 0 deletions src/core/tools/ReadFileTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { ReadFileParams, ReadFileMode, ReadFileToolParams, FileEntry, LineR
import { isLegacyReadFileParams, type ClineSayTool } from "@roo-code/types"

import { Task } from "../task/Task"
import { computeVersionToken } from "../../utils/versionToken"
import { formatResponse } from "../prompts/responses"
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
Expand Down Expand Up @@ -215,12 +216,17 @@ export class ReadFileTool extends BaseTool<"read_file"> {
// Read text file content with lossy UTF-8 conversion
// Reading as Buffer first allows graceful handling of non-UTF8 bytes
// (they become U+FFFD replacement characters instead of throwing)
// Observe the on-disk version before reading so the token can never be newer than
// the bytes this read returns; see observeReadVersion.
const version = await computeVersionToken(fullPath).catch(() => undefined)
const buffer = await fs.readFile(fullPath)
const fileContent = buffer.toString("utf-8")
const result = this.processTextFile(fileContent, entry)

await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)

observeReadVersion(task, fullPath, version)

updateFileResult(relPath, {
nativeContent: `File: ${relPath}\n${result}`,
})
Expand Down Expand Up @@ -770,6 +776,9 @@ export class ReadFileTool extends BaseTool<"read_file"> {
}

// Read text file
// Observe the on-disk version before reading so the token can never be newer than
// the bytes this read returns; see observeReadVersion.
const version = await computeVersionToken(fullPath).catch(() => undefined)
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
Resolved
const rawContent = await fs.readFile(fullPath, "utf8")

// Handle line ranges if specified
Expand Down Expand Up @@ -801,6 +810,8 @@ export class ReadFileTool extends BaseTool<"read_file"> {

// Track file in context
await task.fileContextTracker.trackFileContext(relPath, "read_tool")

observeReadVersion(task, fullPath, version)
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
Resolved
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
results.push(`File: ${relPath}\nError: ${errorMsg}`)
Expand All @@ -816,3 +827,24 @@ export class ReadFileTool extends BaseTool<"read_file"> {
}

export const readFileTool = new ReadFileTool()

/**
* Record (or drop) the version of a file this tool just read, in one place so the
* native and legacy read paths cannot drift apart.
*
* A present token is stored as observed. A missing token means the stat failed, so
* the target is unobserved and any earlier entry must be dropped: that token came from
* a previous read and no longer describes this file, and leaving it would let the write
* guard compare against a stale version. The token itself is taken before the read by
* both call sites, which is deliberate - a token taken afterwards could be newer than
* the content the model saw, and a guard built on it would green-light a stale write.
* The conservative direction is the one chosen here: a mutation during the read yields
* a token that mismatches and blocks the guarded write.
*/
function observeReadVersion(task: Task, fullPath: string, version: string | undefined): void {
if (version) {
task.observationRegistry.observe(fullPath, version)
} else {
task.observationRegistry.forget(fullPath)
}
}
Loading
Loading