Repository navigation
fix(task-persistence): delete under the canonical lock key (U9, #1375) #1917
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a0878e4
404b8df
c1f7fc7
644581d
c0b563b
6ee9aa3
c03b9f4
6409f44
da5acb3
c685a51
7476cc0
c8e69f2
646bb37
87887e1
cfac653
261f6fa
ebcb518
a59746f
1a31785
5365369
d4d81ae
5cf3b3e
1639167
40b2126
9a5c6bb
c78807f
d3de289
1cd6a66
d5f9e25
96f4840
61d28b8
a2748f1
bb8ef2b
f4ee7a0
09a6026
161ca86
4042595
9b74ee5
8e9b591
44e3673
a1ccf47
e09b7db
43fef0c
ac90265
6664b45
e0f655b
ccb9ece
5af828f
d30b54a
f99bfd4
c01245a
1a2320a
3887bcf
d15f73b
634cbe1
316dc7f
231b7cf
67a4225
33802ef
5e645b1
fe197a6
f9360e9
901e54f
0d5eb30
6e5b61c
f10bc21
d26d6c3
a3a88be
411eea9
3198543
1b2ac89
e0b458d
cd7de9d
2dc326f
2a92bd9
3b15a63
2226de8
ea2614d
caba972
cad7265
aa19b7c
a38b594
486e074
1d54a75
3d8681d
3a5ebbb
6c37918
3a952e0
dfc4aa8
8a23831
ff2de55
0d93684
620b755
2b5ccf7
fb14da0
c4bd9a2
c6962e3
ae4cfcb
ed185e2
adb8187
25e4fb8
bc352ea
2281ac0
0fae602
60f9c8c
6093d2a
d82d63e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| 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("forget", () => { | ||
| it("removes the entry and reports that it removed one", () => { | ||
| const registry = new ObservationRegistry() | ||
| registry.observe("/a.ts", "1:1:1:1:1", true) | ||
|
|
||
| expect(registry.forget("/a.ts")).toBe(true) | ||
| expect(registry.has("/a.ts")).toBe(false) | ||
| expect(registry.get("/a.ts")).toBeUndefined() | ||
| expect(registry.size).toBe(0) | ||
| }) | ||
|
|
||
| it("reports false for a path it never observed, and changes nothing", () => { | ||
| const registry = new ObservationRegistry() | ||
| registry.observe("/a.ts", "1:1:1:1:1", true) | ||
|
|
||
| expect(registry.forget("/missing.ts")).toBe(false) | ||
| expect(registry.size).toBe(1) | ||
| expect(registry.get("/a.ts")?.version).toBe("1:1:1:1:1") | ||
| }) | ||
|
|
||
| it("leaves the other entries alone - that is why a caller uses forget and not clear", () => { | ||
| // A caller revoking an authorization it did not earn must not discard the | ||
| // observations other reads of the same task still rely on. | ||
| const registry = new ObservationRegistry() | ||
| registry.observe("/a.ts", "1:1:1:1:1", true) | ||
| registry.observe("/b.ts", "2:2:2:2:2", false) | ||
|
|
||
| expect(registry.forget("/a.ts")).toBe(true) | ||
| expect(registry.get("/b.ts")).toEqual({ | ||
| version: "2:2:2:2:2", | ||
| observedAt: expect.any(Number), | ||
| complete: false, | ||
| }) | ||
| expect(registry.size).toBe(1) | ||
| }) | ||
| }) | ||
|
|
||
| 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) | ||
| }) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| /** | ||
| * 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) | ||
| } | ||
|
|
||
| /** | ||
| * Drop the observation for one path. A caller that must revoke an | ||
| * authorization it did not earn - a preview that observed a version the model | ||
| * never read - needs this instead of clear(), which would also discard the | ||
| * observations other reads of the same task still rely on. | ||
| */ | ||
| forget(absolutePath: string): boolean { | ||
| return this.entries.delete(absolutePath) | ||
| } | ||
|
|
||
| clear(): void { | ||
| this.entries.clear() | ||
| } | ||
|
|
||
| get size(): number { | ||
| return this.entries.size | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ import { TelemetryService } from "@roo-code/telemetry" | |
|
|
||
| import { getReadablePath } from "../../utils/path" | ||
| import { isPathOutsideWorkspace } from "../../utils/pathUtils" | ||
| import { versionTokenOfStat } from "../../utils/versionToken" | ||
| import { Task } from "../task/Task" | ||
| import { formatResponse } from "../prompts/responses" | ||
| import { fileExistsAtPath } from "../../utils/fs" | ||
|
|
@@ -15,6 +16,7 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" | |
| import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" | ||
| import type { DiffResult, ToolUse } from "../../shared/tools" | ||
|
|
||
| import { canonicalizeForApproval } from "./guardedWrite" | ||
| import { BaseTool, ToolCallbacks } from "./BaseTool" | ||
|
|
||
| interface ApplyDiffParams { | ||
|
|
@@ -74,7 +76,32 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { | |
| return | ||
| } | ||
|
|
||
| // The diff below is built from this exact read, so the save that follows must be | ||
| // authorized against the version captured here - not against whatever version the | ||
| // preview happens to stat afterwards. Same contract as ApplyPatchTool's hunk read: | ||
| // stat around the read and observe only when the file did not change underneath it. | ||
| const preReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) | ||
| const originalContent: string = await fs.readFile(absolutePath, "utf-8") | ||
| const postReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) | ||
| if (preReadStats && postReadStats) { | ||
| const preReadToken = versionTokenOfStat(preReadStats) | ||
| if (preReadToken === versionTokenOfStat(postReadStats)) { | ||
| // A tool read is not a model read. With no prior observation this stays a | ||
| // partial observation of the version the diff was computed against - the only | ||
| // authorization the save can have, since apply_diff computes its hunks from | ||
| // this read. When the model already observed the file, keep the completeness it | ||
| // earned, but only on the version it was earned on: refreshing an OLDER | ||
| // observation to the current version would let content the model built from a | ||
| // stale read pass the compare-and-swap, so an out-of-date observation is left | ||
| // alone and the save fails with the re-read remediation. | ||
| const prior = task.observationRegistry.get(absolutePath) | ||
| if (prior === undefined) { | ||
| task.observationRegistry.observe(absolutePath, preReadToken, false) | ||
| } else if (prior.version === preReadToken) { | ||
| task.observationRegistry.observe(absolutePath, preReadToken, prior.complete === true) | ||
| } | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment on lines
+83
to
+104
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| // Apply the diff to the original content | ||
| const diffResult = (await task.diffStrategy?.applyDiff( | ||
|
|
@@ -139,7 +166,19 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { | |
|
|
||
| // Check if file is write-protected | ||
| const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false | ||
|
|
||
| // The guard in guardedWrite refuses a target outside every workspace root unless the tool | ||
| // layer says the user approved this one, and it binds the publish to the identity that was | ||
| // approved. apply_diff has both save paths behind an askApproval, so it has to carry both | ||
| // through; without them an approved write outside the workspace is rejected by its own guard. | ||
| const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) | ||
| // Captured before the approval is asked: guardedWrite binds the publish to this identity, so | ||
| // a name repointed between the approval and the publish is refused instead of publishing to | ||
| // whatever the name points at by then. Inside the try so an unresolvable target goes through | ||
| // the tool's normal error handling and the diff-view reset. | ||
| const approvedCanonicalTarget = isOutsideWorkspace | ||
| ? await canonicalizeForApproval(absolutePath, relPath) | ||
| : undefined | ||
|
|
||
| const sharedMessageProps: ClineSayTool = { | ||
| tool: "appliedDiff", | ||
|
|
@@ -177,7 +216,8 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { | |
| return | ||
| } | ||
|
|
||
| // Save directly without showing diff view or opening the file | ||
| // Save directly without showing diff view or opening the file. The diff is | ||
| // applied to an existing file, so edit-guard semantics require a prior read. | ||
| task.diffViewProvider.editType = "modify" | ||
| task.diffViewProvider.originalContent = originalContent | ||
| await task.diffViewProvider.saveDirectly( | ||
|
|
@@ -186,6 +226,12 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { | |
| false, | ||
| diagnosticsEnabled, | ||
| writeDelayMs, | ||
| "edit", | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| // Seventh parameter is completeOverride (this call claims no completeness of its | ||
| // own); the eighth is the approval flag for a target outside every workspace root. | ||
| undefined, | ||
| isOutsideWorkspace, | ||
| approvedCanonicalTarget, | ||
| ) | ||
| } else { | ||
| // Original behavior with diff view | ||
|
|
@@ -225,7 +271,13 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { | |
| } | ||
|
|
||
| // Call saveChanges to update the DiffViewProvider properties | ||
| await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) | ||
| await task.diffViewProvider.saveChanges( | ||
| diagnosticsEnabled, | ||
| writeDelayMs, | ||
| "edit", | ||
| isOutsideWorkspace, | ||
| approvedCanonicalTarget, | ||
| ) | ||
| } | ||
|
|
||
| // Track file edit operation | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.