F3 SLICE-3 host fixes: disk-compare sync detection + self-minimized seam edits (INV-9) (#6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,12 +8,13 @@
|
||||
* FileSystemWatcher live in CoauthorStore / extension.ts (the sidecar is
|
||||
* co-owned with ThreadController).
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as vscode from "vscode";
|
||||
import { CoauthorStore } from "./store";
|
||||
import { newId, type AttributionRecord, type Provenance } from "./model";
|
||||
import { buildFingerprint, resolve, type OffsetRange } from "./anchorer";
|
||||
import { applyChange, coalesce, type LiveSpan } from "./attributionTracker";
|
||||
import { PendingEditRegistry } from "./pendingEdits";
|
||||
import { minimizeReplace, PendingEditRegistry } from "./pendingEdits";
|
||||
|
||||
/** Test-facing snapshot of live attribution state for a document. */
|
||||
export interface RenderedSpan {
|
||||
@@ -136,8 +137,11 @@ export class AttributionController implements vscode.Disposable {
|
||||
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
||||
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
|
||||
const docPath = this.docPathOf(e.document.uri);
|
||||
if (!e.document.isDirty) {
|
||||
// Disk sync (revert / reload): re-resolve, never attribute (PUC-4).
|
||||
if (!e.document.isDirty && this.matchesDisk(e.document)) {
|
||||
// Disk sync (revert / external reload): buffer now equals the file on
|
||||
// disk — re-resolve, never attribute (PUC-4). A real edit can also
|
||||
// arrive with isDirty still false (VS Code flips the flag after the
|
||||
// change event), but then the buffer no longer matches the disk.
|
||||
this.loadAll(e.document);
|
||||
return;
|
||||
}
|
||||
@@ -152,7 +156,10 @@ export class AttributionController implements vscode.Disposable {
|
||||
};
|
||||
const hit = this.pending.match(docPath, { start: edit.start, end: edit.end, text: change.text });
|
||||
const author = hit ? hit.provenance : this.currentAuthor();
|
||||
s.spans = applyChange(s.spans, edit, author, {
|
||||
// On a seam hit, attribute the agent's FULL intended replacement (the
|
||||
// registered edit is diff-minimized for transport only): same delta,
|
||||
// wider span — the agent owns every char it asserted (INV-9).
|
||||
s.spans = applyChange(s.spans, hit?.full ?? edit, author, {
|
||||
newId: () => newId("at"),
|
||||
now: () => new Date().toISOString(),
|
||||
turnId: hit?.turnId,
|
||||
@@ -162,6 +169,17 @@ export class AttributionController implements vscode.Disposable {
|
||||
this.render(e.document);
|
||||
}
|
||||
|
||||
/** True when the document buffer is byte-identical to the file on disk. */
|
||||
private matchesDisk(document: vscode.TextDocument): boolean {
|
||||
try {
|
||||
return fs.readFileSync(document.uri.fsPath, "utf8") === document.getText();
|
||||
} catch {
|
||||
// Unreadable/missing file: treat as a real edit (attribute), per
|
||||
// fail-open honesty — misclassifying a sync as an edit is recoverable.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the seam (INV-9) ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -170,6 +188,8 @@ export class AttributionController implements vscode.Disposable {
|
||||
* document version or a rejected edit — never partial-applies (spec §6.9).
|
||||
* The pending edit is unregistered unconditionally afterwards (a no-op when
|
||||
* `match` already consumed it), so a no-op edit never leaks a registration.
|
||||
* The replace is self-minimized (common prefix/suffix trimmed) to mirror the
|
||||
* host's WorkspaceEdit diff-minimization, so registered == applied == delivered.
|
||||
*/
|
||||
async applyAgentEdit(
|
||||
document: vscode.TextDocument,
|
||||
@@ -181,17 +201,29 @@ export class AttributionController implements vscode.Disposable {
|
||||
if (!this.isTracked(document)) return false;
|
||||
if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false;
|
||||
const docPath = this.docPathOf(document.uri);
|
||||
const startOffset = document.offsetAt(range.start);
|
||||
const endOffset = document.offsetAt(range.end);
|
||||
const oldText = document.getText(range);
|
||||
const { prefix, suffix } = minimizeReplace(oldText, newText);
|
||||
const minStart = startOffset + prefix;
|
||||
const minEnd = endOffset - suffix;
|
||||
const minText = newText.slice(prefix, newText.length - suffix);
|
||||
if (minStart === minEnd && minText.length === 0) {
|
||||
// Replacement equals the existing text — a no-op; nothing to attribute.
|
||||
return true;
|
||||
}
|
||||
const pendingEdit = {
|
||||
docPath,
|
||||
start: document.offsetAt(range.start),
|
||||
end: document.offsetAt(range.end),
|
||||
newText,
|
||||
start: minStart,
|
||||
end: minEnd,
|
||||
newText: minText,
|
||||
provenance,
|
||||
turnId: opts?.turnId,
|
||||
full: { start: startOffset, end: endOffset, newLength: newText.length },
|
||||
};
|
||||
this.pending.register(pendingEdit);
|
||||
const we = new vscode.WorkspaceEdit();
|
||||
we.replace(document.uri, range, newText);
|
||||
we.replace(document.uri, new vscode.Range(document.positionAt(minStart), document.positionAt(minEnd)), minText);
|
||||
const ok = await vscode.workspace.applyEdit(we);
|
||||
this.pending.unregister(pendingEdit);
|
||||
return ok;
|
||||
|
||||
Reference in New Issue
Block a user