From decdfbf31d80e6565dd1e832e599f5a6e9cd67fe Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 09:51:19 -0700 Subject: [PATCH] F3 SLICE-3: pending-edit registry + sidecar section-merge update (INV-9 groundwork) (#6) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pendingEdits.ts | 48 +++++++++++++++++++++++++++++++++++++++ src/store.ts | 23 ++++++++++++++++++- src/threadController.ts | 7 +++++- test/pendingEdits.test.ts | 32 ++++++++++++++++++++++++++ test/store.test.ts | 34 +++++++++++++++++++++++++++ 5 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 src/pendingEdits.ts create mode 100644 test/pendingEdits.test.ts diff --git a/src/pendingEdits.ts b/src/pendingEdits.ts new file mode 100644 index 0000000..28c3995 --- /dev/null +++ b/src/pendingEdits.ts @@ -0,0 +1,48 @@ +/** + * PendingEditRegistry — the seam's bookkeeping (spec §6.2, INV-9). Before + * applyAgentEdit applies a WorkspaceEdit, it registers the exact expected + * change; the controller's change handler consumes a matching registration to + * attribute that change to the agent. Anything unmatched is human typing. + * vscode-free and unit-testable. + */ +import type { Provenance } from "./model"; + +export interface PendingEdit { + docPath: string; + /** half-open [start, end) offsets of the replaced range. */ + start: number; + end: number; + newText: string; + provenance: Provenance; + turnId?: string; +} + +export class PendingEditRegistry { + private pending: PendingEdit[] = []; + + register(edit: PendingEdit): void { + this.pending.push(edit); + } + + /** Remove a registration that failed to apply. */ + unregister(edit: PendingEdit): void { + this.pending = this.pending.filter((p) => p !== edit); + } + + /** + * Find-and-consume the registration exactly matching a change event + * (same doc, same replaced range, same inserted text). Null → human edit. + */ + match(docPath: string, change: { start: number; end: number; text: string }): PendingEdit | null { + const i = this.pending.findIndex( + (p) => + p.docPath === docPath && + p.start === change.start && + p.end === change.end && + p.newText === change.text, + ); + if (i === -1) return null; + const [hit] = this.pending.splice(i, 1); + return hit; + } +} diff --git a/src/store.ts b/src/store.ts index f96acd3..b9a93bd 100644 --- a/src/store.ts +++ b/src/store.ts @@ -6,7 +6,7 @@ */ import * as fs from "node:fs"; import * as path from "node:path"; -import { serializeArtifact, type Artifact } from "./model"; +import { emptyArtifact, serializeArtifact, type Artifact } from "./model"; export class CoauthorStore { /** @param rootDir absolute workspace-folder root that owns the `.threads/` tree. */ @@ -28,4 +28,25 @@ export class CoauthorStore { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, serializeArtifact(artifact), "utf8"); } + + /** + * Read-modify-write for sidecar co-ownership (spec F3 §6.2): each controller + * mutates only its own section against a FRESH load, so ThreadController and + * AttributionController never clobber each other. After the mutation, anchors + * referenced by neither threads nor attributions are pruned (proposals are + * still empty in F3 — revisit in F4). + */ + update(docPath: string, mutate: (artifact: Artifact) => void): Artifact { + const artifact = this.load(docPath) ?? emptyArtifact(docPath); + mutate(artifact); + const referenced = new Set([ + ...artifact.threads.map((t) => t.anchorId), + ...artifact.attributions.map((a) => a.anchorId), + ]); + for (const id of Object.keys(artifact.anchors)) { + if (!referenced.has(id)) delete artifact.anchors[id]; + } + this.save(docPath, artifact); + return artifact; + } } diff --git a/src/threadController.ts b/src/threadController.ts index 0631440..f0a04f7 100644 --- a/src/threadController.ts +++ b/src/threadController.ts @@ -84,7 +84,12 @@ export class ThreadController implements vscode.Disposable { private persist(state: DocState): void { this.selfWrites.add(this.store.sidecarPath(state.docPath)); - this.store.save(state.docPath, state.artifact); + this.store.update(state.docPath, (a) => { + a.threads = state.artifact.threads; + for (const t of state.artifact.threads) { + a.anchors[t.anchorId] = state.artifact.anchors[t.anchorId]; + } + }); } private ensureState(document: vscode.TextDocument): DocState { diff --git a/test/pendingEdits.test.ts b/test/pendingEdits.test.ts new file mode 100644 index 0000000..622941f --- /dev/null +++ b/test/pendingEdits.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { PendingEditRegistry } from "../src/pendingEdits"; +import type { Provenance } from "../src/model"; + +const AGENT: Provenance = { + kind: "agent", id: "claude", + agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" }, +}; + +describe("PendingEditRegistry (INV-9)", () => { + it("matches and consumes an exact registration", () => { + const reg = new PendingEditRegistry(); + reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT, turnId: "t1" }); + const hit = reg.match("d.md", { start: 3, end: 7, text: "new" }); + expect(hit?.turnId).toBe("t1"); + expect(reg.match("d.md", { start: 3, end: 7, text: "new" })).toBeNull(); + }); + it("does not match a different doc, range, or text (human typing stays human)", () => { + const reg = new PendingEditRegistry(); + reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT }); + expect(reg.match("other.md", { start: 3, end: 7, text: "new" })).toBeNull(); + expect(reg.match("d.md", { start: 3, end: 8, text: "new" })).toBeNull(); + expect(reg.match("d.md", { start: 3, end: 7, text: "neww" })).toBeNull(); + }); + it("unregister removes a failed application", () => { + const reg = new PendingEditRegistry(); + const p = { docPath: "d.md", start: 0, end: 0, newText: "x", provenance: AGENT }; + reg.register(p); + reg.unregister(p); + expect(reg.match("d.md", { start: 0, end: 0, text: "x" })).toBeNull(); + }); +}); diff --git a/test/store.test.ts b/test/store.test.ts index 20eb736..77def9d 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -54,4 +54,38 @@ describe("CoauthorStore", () => { expect(second).toBe(first); expect(first).toBe(serializeArtifact(a)); }); + + it("update(): two writers merge sections instead of clobbering (F3 §6.2)", () => { + const store = new CoauthorStore(root); + store.update("d.md", (a) => { + a.anchors["a_t"] = { fingerprint: { text: "t", before: "", after: "", lineHint: 0 } }; + a.threads.push({ id: "t1", anchorId: "a_t", status: "open", messages: [] }); + }); + store.update("d.md", (a) => { + a.anchors["a_at"] = { fingerprint: { text: "x", before: "", after: "", lineHint: 0 } }; + a.attributions.push({ + id: "at1", anchorId: "a_at", author: { kind: "human", id: "ben" }, + createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z", + }); + }); + const loaded = store.load("d.md")!; + expect(loaded.threads).toHaveLength(1); + expect(loaded.attributions).toHaveLength(1); + expect(Object.keys(loaded.anchors).sort()).toEqual(["a_at", "a_t"]); + }); + + it("update() prunes anchors no longer referenced by threads or attributions", () => { + const store = new CoauthorStore(root); + store.update("d.md", (a) => { + a.anchors["a_old"] = { fingerprint: { text: "o", before: "", after: "", lineHint: 0 } }; + a.attributions.push({ + id: "at1", anchorId: "a_old", author: { kind: "human", id: "ben" }, + createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z", + }); + }); + store.update("d.md", (a) => { + a.attributions = []; + }); + expect(Object.keys(store.load("d.md")!.anchors)).toEqual([]); + }); });