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
83b4a96
split unit U1 of PR 1833 (issue 1375)
Oct 5, 2026
f9216d7
fix(file-safety): close the pre-merge findings on the publish primiti…
Oct 5, 2026
96655c7
fix(file-safety): propagate a non-ENOENT lstat failure in resolvePubl…
Oct 5, 2026
a7af8f1
fix(file-safety): keep the rollback pair typed and the mock stand-ins…
Oct 5, 2026
d853019
rebuild unit u2 on the fixed chain
Oct 5, 2026
49943f8
chore(lint): prune the safeWriteJson suppression this unit earns
Oct 5, 2026
3cb97b2
rebuild unit u3 on the fixed chain
Oct 5, 2026
3a14c48
fix(task): declare the observation registry on Task in this unit
Oct 5, 2026
d6bcf47
rebuild unit u4 on the fixed chain
Oct 5, 2026
ca084b1
rebuild unit u5 on the fixed chain
Oct 5, 2026
e739fb9
chore(ci): rerun the Windows test lane
Oct 5, 2026
16988d8
fix(tools): the clipping notice must agree with the recorded complete…
Oct 5, 2026
315c69a
test(file-safety): check the paths the cleanup assertions actually name
Oct 5, 2026
36cd1ab
test(file-safety): fix the readlink mock parameter type
Oct 5, 2026
ceafb44
fix(file-safety): inherit unit 1 committed guard
Oct 5, 2026
d3135a3
fix(file-safety): keep the publish error message in RollbackFailureError
Oct 5, 2026
ffb9d67
test: re-trigger required checks - the queued runs were cancelled by …
Oct 5, 2026
563a3c7
fix(file-safety): give the Windows DACL dump a per-write name
Oct 5, 2026
a8aadc8
test(file-safety): cover the committed guard in backup mode and align…
Oct 5, 2026
0f2cf5d
fix(tools): confine a guarded write to the task workspace
Oct 7, 2026
a7dc10b
test(tools): type the realpath double for the containment cases
Oct 7, 2026
709fbd8
fix(tools): fail closed on an unresolvable workspace and re-check con…
Oct 7, 2026
9bc3e64
fix(tools,fileLock): stop indenting the clipped view, canonicalize th…
Oct 7, 2026
1331d0d
fix(tools): stop a queued write from surviving a cancel-then-resume
Oct 8, 2026
17e7e59
fix(tools): make the guarded create an atomic no-replace publish
Oct 8, 2026
3db87ee
fix(tools): pin the publish to the target that was authorized
Oct 8, 2026
44db939
fix(file-safety): keep the create path working and the lock key stabl…
Oct 8, 2026
b28abb9
fix(guardedWrite): fail closed on an unresolvable workspace and pin t…
Oct 8, 2026
6835cde
fix(u5): drop the duplicated failIfExist key the assertion rewrite le…
Oct 8, 2026
f353b89
fix(file-safety): one lock key whether the parent directory exists yet
Oct 8, 2026
aa25af0
fix(u5): refuse a path that runs through a dangling link
Oct 8, 2026
d082d49
test(u5): the pin and the publish must agree on an aliased ancestor
Oct 8, 2026
67f79b6
fix(file-safety): take U1's copy-never-move backup into this unit
Oct 8, 2026
e2674db
fix(u5): align the unit with main's lint re-enable and format gate
easonLiangWorldedtech Oct 14, 2026
6375a09
fix(u5): restore the unicode spec's clipped-lines expectations the au…
easonLiangWorldedtech Oct 14, 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
7 changes: 7 additions & 0 deletions .msg.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
fix(u5): restore the unicode spec's clipped-lines expectations the auto-merge dropped

The rebase onto main's new unicode tests auto-merged them in but lost this
unit's hasClippedLines additions to the expected objects, so the three
clipping tests asserted the pre-feature shape and failed on CI (and
locally). Restoring the unit's spec content: 76 tests green across the
indentation-reader specs.
13 changes: 13 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,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 @@ -282,6 +283,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 @@ -379,6 +384,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
providerRef: WeakRef<ClineProvider>
private readonly globalStoragePath: string
abort: boolean = false
// Monotonic cancellation counter. `abort` is a mutable flag that resumeAfterDelegation
// resets to false, so a write still waiting on a path chain cannot tell from `abort`
// alone that the task was cancelled while it waited - the flag may be back to false by
// the time its turn comes. Every cancellation bumps this counter, and the guarded-write
// path compares the value it captured when the write was queued.
cancellationGeneration: number = 0
currentRequestAbortController?: AbortController
/**
* Controller for the waiter on an in-flight `ensureModelFetched()` call (see
Expand Down Expand Up @@ -3449,6 +3460,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}

this.abort = true
this.cancellationGeneration++
this.cancelAssistantMessagePersistence()
this.abortPromise ??= this.abortTaskOnce()
return this.abortPromise
Expand Down Expand Up @@ -3534,6 +3546,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// signals below are cancelled and a request already past those checks
// could still build tools and call `createMessage()`.
this.abort = true
this.cancellationGeneration++

// Cancel any in-progress HTTP request
try {
Expand Down
48 changes: 48 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4107,6 +4107,54 @@ describe("Cline", () => {
).toHaveLength(1)
})

it("advances the cancellation generation on each cancellation and keeps it advanced across a resume", async () => {
// The S4a write guard compares the cancellation generation a queued write
// captured against the generation at publish time. The abort FLAG cannot do that
// on its own: resumeAfterDelegation() clears it, and a write that belongs to the
// cancelled run would then publish as if nothing had been cancelled. Only the
// generation keeps the two runs apart, so it must never go backwards.
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})
vi.spyOn(task, "dispose").mockResolvedValue(undefined)

expect(task.cancellationGeneration).toBe(0)

await task.abortTask()
expect(task.abort).toBe(true)
expect(task.cancellationGeneration).toBe(1)

await task.resumeAfterDelegation()
expect(task.abort).toBe(false)
expect(task.cancellationGeneration).toBe(1)

await task.abortTask()
expect(task.abort).toBe(true)
expect(task.cancellationGeneration).toBe(2)
})

it("advances the cancellation generation on disposal, without any explicit cancel", async () => {
// A task torn down by the host never sees abortTask(). Disposal has to raise the
// same generation, or a write still parked on its path chain publishes after the
// task is gone.
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})

expect(task.cancellationGeneration).toBe(0)

await task.dispose()

expect(task.abort).toBe(true)
expect(task.cancellationGeneration).toBe(1)
})

it("flushes pending state before TaskAborted and disposal while queue state is intact", async () => {
const task = new Task({
provider: mockProvider,
Expand Down
108 changes: 108 additions & 0 deletions src/core/task/__tests__/observationRegistry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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)
})
})
})
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
}
}
Loading
Loading