Repository navigation
feat(file-safety): guard machinery in the writer, with the identity and rights refusals (U5-B, split 1/2 of #1914, #1375) #2357
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
1823e82
bef023d
12b57c1
984c6ed
20136ce
b524863
93329bd
0f32b3e
8372003
074348e
aaf2434
d9ee95a
fedb77d
28737be
e53a6e0
bb34958
62705bb
b894375
b9cfd73
afdd517
13c2902
2955c80
a6dc245
bfff215
677478f
bcce2c3
4291ced
40b9c39
d5ff976
ae8fda5
0deb7f4
ddae96e
e12880f
7bcc158
ce174a2
25b02ca
cc76cbf
5a6b43e
f5a2a50
ea00f9f
325a0a2
0fe578c
d73231e
56fe7a0
1f4169e
fcadb91
a53d162
0cc6a0a
e6b9f65
3f8f9d7
c0120aa
ef1044f
011451e
f60aaeb
5e45671
a15ada1
5937180
9787df6
9ed41eb
e62f198
4391a8b
279e77d
c0b57f7
e3d6327
1871993
7bf2654
f739a5f
b86511f
416179d
120cb05
5841dd2
b328ded
8a9d5b7
87d4aff
25a1ad6
956208e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4769,6 +4769,56 @@ 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) | ||
|
Comment on lines
+4784
to
+4790
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.
|
||
|
|
||
| await task.resumeAfterDelegation() | ||
| // The run-state model (RSK-06) no longer clears the legacy `abort` flag | ||
| // on resume: that is exactly why the generation exists. Only the | ||
| // generation keeps the cancelled run and the resumed run apart. | ||
| 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, | ||
|
|
@@ -7762,6 +7812,9 @@ describe("Cline", () => { | |
| // task directory to exist (uuid v7 is mocked to the fixed id below). | ||
| const taskDir = path.join(os.tmpdir(), "test-storage", "tasks", "00000000-0000-7000-8000-000000000000") | ||
| fsReal.mkdirSync(taskDir, { recursive: true }) | ||
| // Earlier tests in this file can leave a ui_messages.json for the fixed task id; | ||
| // merge=true would read it here and fail the first save stage for an unrelated reason. | ||
| fsReal.rmSync(path.join(taskDir, "ui_messages.json"), { force: true }) | ||
| // Declared outside the try so the finally block can restore them. | ||
| const updateSpy = vi | ||
| .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") | ||
|
|
||
| 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) | ||
| }) | ||
| }) | ||
| }) |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 27076
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 25761
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 22796
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 21855
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 11821
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 2129
Web query:
Node.js fs promises writeFile default flag w replaces existing file contents official documentationResult:
Enforce observations in the guarded-write follow-up.
With
preventFocusDisruptionenabled, an approved full-file write can overwrite unseen or newer content.write_to_filedoes not check the recorded completeness or version beforesaveDirectlyreplaces the file. Connect both checks to this publishing path in the follow-up.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
vps2-f triage. Not #2357's content: git diff 416179d 8a9d5b7 is empty for this file, so the line arrived from #1913's head and rides here only because main does not contain 416179d yet. The row's home is #1913. Line 302, the readonly observationRegistry field, is not in this unit's added lines either: the Task.ts additions here are 401-405, 3604-3608, 3619-3622 and 3651. The claim about the publishing path is accurate for the pre-existing write_to_file and saveDirectly route. It is not true of the tool this stack is shipping: guardedWrite, on #2359's head, consults task.observationRegistry at guardedWrite.ts:573, requires a complete observation for a full-file publish, rejects stale when the on-disk token differs, and refreshes the observation after publish at 657 and 679. What remains open is migrating the legacy tool onto that path, which the split plan assigns to neither #2357 nor #2359. I am not changing code in this pass and I am not resolving the thread.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Analysis chain
Script executed:
Length of output: 14487
@easonLiangWorldedtechI agree this request is outside#2357’s unit scope. I withdraw my request for a change in this PR.The comparison from
416179dto8a9d5b7changesTask.ts, but it does not addobservationRegistry. It addscancellationGeneration. The legacyWriteToFileTool.tsandDiffViewProvider.tsfiles are unchanged in that comparison.On
#2359’s checked head,guardedWriterequires a complete observation for an existing full-file replacement and passes the observed version toreplaceIfVersion. It does not migratewrite_to_file.The thread will remain open, as requested. Would you like a follow-up issue for the legacy tool migration?
Learnings used
You are interacting with an AI system.