Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 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
77eb0a4
rebuild unit u4 on the fixed chain
Oct 5, 2026
d7eab3d
chore(lint): prune the readFileTool.spec suppression this unit earns
Oct 5, 2026
823acfe
fix(file-safety): inherit unit 1 committed guard and exact rmdir asse…
Oct 5, 2026
347c56d
fix(tools): stop the source indentation leaking into the clipped-line…
Oct 5, 2026
18f5c12
fix(file-safety): keep the publish error message in RollbackFailureError
Oct 5, 2026
08f281d
test(file-safety): assert the exact staging directory removed after a…
Oct 5, 2026
a1b9823
test: re-trigger required checks - the queued runs were cancelled by …
Oct 5, 2026
a9bc6a4
fix(file-safety): give the Windows DACL dump a per-write name
Oct 5, 2026
c1e4169
fix(tools): the clipping notice must describe the slice it actually r…
Oct 5, 2026
e7a5580
fix(file-safety): keep the target present while publishing (durable c…
Oct 7, 2026
31ffa4b
feat(utils): let a caller confine a write to a directory
Oct 7, 2026
467cfda
fix(file-safety): do not read a failed target lstat as a missing target
Oct 7, 2026
03c0725
fix(file-safety): compare staging and target identity with bigint stats
Oct 7, 2026
1a59f51
test(file-safety): pin the bigint options in the staging-identity tests
Oct 7, 2026
1331a91
fix(file-safety): report a Windows replacement whose DACL was not pre…
Oct 7, 2026
e412fce
fix(file-safety): keep DACL warning delivery from failing the save
Oct 7, 2026
bcd178c
fix(utils): check confinement before taking the advisory lock
Oct 7, 2026
244f4b6
fix(file-safety): handle async warning sinks and confine before mkdir
Oct 7, 2026
6fc470c
test(file-safety,utils): cover the commit-rename failure and unmock w…
Oct 7, 2026
87986e9
test(task): pin that each Task owns its observation registry
Oct 8, 2026
72fdd55
fix(mcp): confine project MCP writes to the workspace root
Oct 8, 2026
5596f3a
fix(task,tools): retire the observation registry on disposal and pin …
Oct 8, 2026
949c263
fix(u4): re-check cancellation after the post-read stat before observing
Oct 8, 2026
f1fb1b2
fix(file-safety): one lock key whether the parent directory exists yet
Oct 8, 2026
7dec01b
test(file-safety): a backup copy that fails part-way must not survive…
Oct 8, 2026
c36fd64
chore(ci): no-op commit to re-trigger the review for this head
Oct 8, 2026
c7f51ce
test(tools,mcp): count the observation stats and name the pinned write
Oct 10, 2026
5ee6dbb
style(test): reflow three pre-existing lines to the repo prettier gate
Oct 10, 2026
9ccf58a
Merge upstream main ecab51985709faeb2a1f35f23179d98f85abc485 into fws…
Oct 10, 2026
d6f7c58
style(tools,file-safety,utils): apply the repo prettier gate to the s…
Oct 10, 2026
1d0e991
test(misc): teach the Unicode reader spec the hasClippedLines field
Oct 10, 2026
7e58e5f
fix(tools): drop the duplicate Task import the merge produced
Oct 10, 2026
be4e38d
fix(tools): correct the merged no-explicit-any count for the read-fil…
Oct 10, 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
12 changes: 12 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 Expand Up @@ -3491,6 +3496,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
console.error("Error removing event listeners:", error)
}

// A disposed task is no longer authoritative for what it read. The registry holds
// version tokens captured while the task was alive, and a disposed task can still be
// reachable through a parent/subtask reference; a guarded write must not accept one of
// those tokens for a file this task has not re-read since. Clearing also stops a long
// task from pinning every file it ever read.
this.observationRegistry.close()

// Release any terminals associated with this task.
try {
// Release any terminals associated with this task.
Expand Down
50 changes: 50 additions & 0 deletions src/core/task/__tests__/Task.dispose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from "node:path"
import { type ProviderSettings, RooCodeEventName } from "@roo-code/types"

import { Task } from "../Task"
import { ObservationRegistry } from "../observationRegistry"
import { ClineProvider } from "../../webview/ClineProvider"
import { OutputInterceptor } from "../../../integrations/terminal/OutputInterceptor"
import { providerIdentifiers } from "@roo-code/types/provider-identifiers"
Expand Down Expand Up @@ -118,6 +119,55 @@ describe("Task dispose method", () => {
expect(disposalComplete).toBe(true)
})

test("owns a per-Task observation registry", () => {
// ReadFileTool and the guarded-write path reach the registry only through the Task,
// so a Task that never built one - or shared one across Tasks - would silently drop
// read observations. The registry's own tests only exercise standalone instances, and
// the tool tests inject their own doubles, so nothing else pins this wiring.
expect(task.observationRegistry).toBeInstanceOf(ObservationRegistry)

const other = new Task({
provider: mockProvider as unknown as ClineProvider,
apiConfiguration: mockApiConfiguration,
startTask: false,
})
try {
expect(other.observationRegistry).toBeInstanceOf(ObservationRegistry)
expect(other.observationRegistry).not.toBe(task.observationRegistry)

// Observing in one Task must not be visible from another: parent and subtask
// authority over a file has to stay separate.
other.observationRegistry.observe("/workspace/a.ts", "v1", true)
expect(other.observationRegistry.get("/workspace/a.ts")?.version).toBe("v1")
expect(task.observationRegistry.has("/workspace/a.ts")).toBe(false)
expect(task.observationRegistry.size).toBe(0)
} finally {
void other.dispose().catch(() => {})
}
})

test("clears the per-Task observation registry on disposal", async () => {
// The registry holds on-disk version tokens for files this task read. Disposal does not
// free the Task object - a parent or subtask reference can outlive it - so a token
// captured before teardown must not survive as authority for a guarded write.
task.observationRegistry.observe("/workspace/a.ts", "v1", true)
task.observationRegistry.observe("/workspace/b.ts", "v2", false)
expect(task.observationRegistry.size).toBe(2)

await task.dispose()

expect(task.observationRegistry.size).toBe(0)
expect(task.observationRegistry.has("/workspace/a.ts")).toBe(false)
expect(task.observationRegistry.get("/workspace/b.ts")).toBeUndefined()

// A read that was still awaiting I/O when disposal began resumes afterwards. Its
// observation must not land in a retired registry, or the disposed Task would be
// authoritative for that file again.
expect(task.observationRegistry.closed).toBe(true)
task.observationRegistry.observe("/workspace/late.ts", "v3", true)
expect(task.observationRegistry.size).toBe(0)
})

test("should reject the memoized completion promise when disposal cannot start", async () => {
const disposalError = new Error("disposal failed")
skipCleanup = true
Expand Down
132 changes: 132 additions & 0 deletions src/core/task/__tests__/observationRegistry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, it, expect, vi } from "vitest"

import { ObservationRegistry } from "../observationRegistry"

describe("ObservationRegistry", () => {
it("observe → get returns the recorded version and observedAt", () => {
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(typeof obs!.observedAt).toBe("number")
})

it("re-observe replaces the entry with a fresh observedAt", () => {
vi.useFakeTimers()
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)

vi.useRealTimers()
})

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)
})
})

describe("closure on task disposal", () => {
it("close drops every entry and reports the registry closed", () => {
const reg = new ObservationRegistry()
reg.observe("/a/b/c.ts", "v1")
expect(reg.closed).toBe(false)

reg.close()

expect(reg.size).toBe(0)
expect(reg.get("/a/b/c.ts")).toBeUndefined()
expect(reg.closed).toBe(true)
})

it("observe is a no-op after close, so a read resuming after disposal records nothing", () => {
const reg = new ObservationRegistry()
reg.close()

reg.observe("/late.ts", "v1")

expect(reg.size).toBe(0)
expect(reg.has("/late.ts")).toBe(false)
})
})
})
84 changes: 84 additions & 0 deletions src/core/task/observationRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* 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>()
// Set once the owning Task starts disposal. Reads that were already awaiting I/O when
// disposal began resume afterwards, and without this flag they would repopulate a
// registry that disposeOnce() has just retired.
private retired = false

/**
* 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 {
if (this.retired) {
return
}
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()
}

/**
* Retire the registry: drop every entry and refuse later observations.
*
* Task.disposeOnce() calls this so a task that is being torn down cannot end up holding
* observations again - an awaited read that resumes after disposal would otherwise
* re-record the file it had already read, leaving a disposed Task (still reachable
* through a parent/subtask reference) with authority it no longer deserves.
*/
close(): void {
this.retired = true
this.entries.clear()
}

/** Whether close() has already run; observations are ignored once this is true. */
get closed(): boolean {
return this.retired
}

get size(): number {
return this.entries.size
}
}
Loading
Loading