Repository navigation
fix(workspace): materialize refuses files whose disk copy changed since last write #169
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
Changes from all commits
ff1c148
04ec1d9
f0e359f
bb16e5b
6093da5
e667499
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 |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| - **`materialize` no longer silently overwrites disk files changed by another writer** (#109). Before writing, each file's disk hash is compared against what the workspace layer last materialized; a mismatch means a shell/tool edit happened since, and the path is refused and listed in the result (`skipped`, with the remedy) instead of silently reverted. A disk copy that exists but cannot be read (e.g. a write-only file) is refused the same way, with the filesystem error in the reason, rather than treated as absent. `force: true` remains the deliberate override. Refused paths stay owed to every later materialize (and count in `status.pendingChanges`) without holding back the sequence the branch guard reads. `sync` re-pins the baseline when it adopts a disk copy, so the agent's next edit to that file is not refused. Baselines and refused paths persist across restarts, and a first materialize that finds every file already in place still pins the branch. Files whose disk copy already matches the target bytes are left untouched (mtime preserved). The post-undo/redo `_config` restore keeps its overwrite semantics. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -119,6 +119,7 @@ export async function syncFromFs( | |
| store.treeRemove(mount.treeStateId, relativePath); | ||
| result.synced.push({ path: relativePath, op: 'deleted' }); | ||
| } | ||
| mount.materializedHashes.delete(relativePath); | ||
| continue; | ||
| } | ||
|
|
||
|
|
@@ -153,7 +154,8 @@ export async function syncFromFs( | |
| const existing = store.treeGet(mount.treeStateId, relativePath); | ||
|
|
||
| if (existing && existing.blobHash === hash) { | ||
| // No change | ||
| // No change. Disk and tree agree, so this is the baseline now. | ||
| mount.materializedHashes.set(relativePath, hashContent(buffer)); | ||
| continue; | ||
| } | ||
|
|
||
|
|
@@ -181,6 +183,12 @@ export async function syncFromFs( | |
| } | ||
|
|
||
| store.treeSet(mount.treeStateId, relativePath, entry); | ||
| // The tree adopted the disk copy, so disk and tree agree: re-pin the | ||
| // baseline. Left at the old hash, materialize would read the adopted | ||
| // edit as a fresh external change and refuse every later agent edit | ||
| // to this file, with no sync able to clear it. Raw disk bytes, which is | ||
| // what materializeToFs compares against. | ||
| mount.materializedHashes.set(relativePath, hashContent(buffer)); | ||
| result.synced.push({ path: relativePath, op: existing ? 'modified' : 'created' }); | ||
| } catch (error) { | ||
| result.skipped.push({ | ||
|
|
@@ -193,24 +201,55 @@ export async function syncFromFs( | |
| return result; | ||
| } | ||
|
|
||
| export interface MaterializeResult { | ||
| /** Paths actually written to disk */ | ||
| written: string[]; | ||
| /** Paths whose disk copy already held exactly the tree's bytes: nothing | ||
| * written, but disk reflects the current tree for them all the same. */ | ||
| unchanged: string[]; | ||
| /** Paths refused by the freshness guard, each with the reason — a silently | ||
| * overwritten shell edit is unrecoverable, so divergence must surface | ||
| * instead of being resolved toward the workspace copy (issue #109: a bulk | ||
| * materialize reverted 217 shell-edited files to a stale snapshot). */ | ||
| skipped: SkippedFile[]; | ||
| } | ||
|
|
||
| /** | ||
| * Materialize Chronicle tree state to filesystem. | ||
| * | ||
| * Freshness guard (#109): files get modified by two hands — this layer AND | ||
| * direct shell/tool edits. Before overwriting, the disk content's hash is | ||
| * compared against `materializedHashes` (the hash this layer last wrote): a | ||
| * mismatch means another writer changed the file since, and the path is | ||
| * refused loudly instead of silently reverted, unless `force`. Symmetric | ||
| * with syncFromFs's conflict detection, and with the same blind spot: no | ||
| * baseline (never materialized, or synced) means no guard. Baselines and | ||
| * refused paths persist across restarts with the module state. | ||
| * | ||
| * Refused paths are kept in `mount.refusedPaths` and retried by every later | ||
| * materialize, so the diff watermark can still advance: holding it back | ||
| * would understate what disk holds to the branch guard, which reads the | ||
| * same sequence. | ||
| * | ||
| * @param store Chronicle store | ||
| * @param mount Mount state | ||
| * @param paths Specific paths to materialize. If undefined, materializes all changed since last. | ||
| * @returns List of paths that were written | ||
| * @param opts force: overwrite even where the disk copy changed under us. | ||
| * @returns Paths written, and paths the freshness guard refused | ||
| */ | ||
| export async function materializeToFs( | ||
| store: JsStore, | ||
| mount: MountState, | ||
| paths?: string[], | ||
| ): Promise<string[]> { | ||
| opts?: { force?: boolean }, | ||
| ): Promise<MaterializeResult> { | ||
| if (mount.config.mode === 'read-only') { | ||
| return []; | ||
| return { written: [], unchanged: [], skipped: [] }; | ||
| } | ||
|
|
||
| const written: string[] = []; | ||
| const unchanged: string[] = []; | ||
| const skipped: SkippedFile[] = []; | ||
|
|
||
| // Get changed files since last materialization | ||
| const currentSeq = store.currentSequence(); | ||
|
|
@@ -255,13 +294,68 @@ export async function materializeToFs( | |
| })); | ||
| } | ||
|
|
||
| // Paths an earlier materialize refused are still owed. A path the tree no | ||
| // longer has is owed nothing. | ||
| if (!paths) { | ||
| const queued = new Set(filesToMaterialize.map((f) => f.path)); | ||
| for (const p of mount.refusedPaths) { | ||
| if (queued.has(p)) continue; | ||
| const entry = store.treeGet(mount.treeStateId, p); | ||
| if (entry) filesToMaterialize.push({ path: p, blobHash: entry.blobHash }); | ||
| else mount.refusedPaths.delete(p); | ||
| } | ||
| } | ||
|
|
||
| for (const { path: relativePath, blobHash } of filesToMaterialize) { | ||
| const absolutePath = safePath(mount.config.path, relativePath); | ||
| if (!absolutePath) continue; // Path traversal — skip silently | ||
|
|
||
| const blob = store.getBlob(blobHash); | ||
| if (!blob) continue; | ||
|
|
||
| let diskBuffer: Buffer | null = null; | ||
| try { | ||
| diskBuffer = await readFile(absolutePath); | ||
| } catch (err) { | ||
| // Only absence proves there is nothing to overwrite. Any other read | ||
| // failure (EACCES on a write-only file, EISDIR, EIO...) means freshness | ||
| // cannot be verified, and writeFile may still succeed — so refuse | ||
| // rather than treat "unreadable" as "not there". | ||
| const code = (err as NodeJS.ErrnoException).code; | ||
| if (code !== 'ENOENT' && !opts?.force) { | ||
| skipped.push({ | ||
| path: relativePath, | ||
| reason: | ||
| `cannot verify disk copy (${code ?? (err as Error).message}) — ` + | ||
| 'fix its permissions and materialize again, or pass force to overwrite it', | ||
| }); | ||
| mount.refusedPaths.add(relativePath); | ||
| continue; | ||
| } | ||
| } | ||
| if (diskBuffer) { | ||
| const diskHash = hashContent(diskBuffer); | ||
| if (diskHash === blobHash) { | ||
| // Disk already holds exactly these bytes: re-pin the baseline and | ||
| // leave the file (and its mtime) alone. | ||
| mount.materializedHashes.set(relativePath, blobHash); | ||
| mount.refusedPaths.delete(relativePath); | ||
| unchanged.push(relativePath); | ||
| continue; | ||
| } | ||
| const baselineHash = mount.materializedHashes.get(relativePath); | ||
| if (!opts?.force && baselineHash !== undefined && diskHash !== baselineHash) { | ||
|
Comment on lines
+346
to
+347
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.
Prompt To Fix With AIThis is a comment left during a code review.
Path: src/modules/workspace/sync.ts
Line: 313-314
Comment:
**Restart leaves disk edits unguarded** Restart restores the materialization sequence but creates an empty `materializedHashes` map. On a `watch: 'never'` mount, if a workspace edit is pending and the disk copy was shell-edited, the first materialize after restart has no baseline to compare here. It can silently overwrite the shell edit instead of refusing it.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| skipped.push({ | ||
| path: relativePath, | ||
| reason: | ||
| 'stale copy: disk changed since last materialize (another writer) — ' + | ||
| 'sync first to adopt the disk version, or pass force to overwrite it', | ||
|
Comment on lines
+346
to
+352
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.
Prompt To Fix With AIThis is a comment left during a code review.
Path: src/modules/workspace/sync.ts
Line: 313-319
Comment:
**Synced edits remain blocked** After `sync` adopts a shell edit, it updates the tree but leaves `materializedHashes` at the old value. If the agent then edits that file before materializing, this check treats the already-adopted disk content as a new external change and refuses the write on every attempt. Running `sync` again does not fix the stale baseline.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| }); | ||
| mount.refusedPaths.add(relativePath); | ||
| continue; | ||
| } | ||
| } | ||
|
|
||
| // Create parent directories | ||
| await mkdir(dirname(absolutePath), { recursive: true }); | ||
|
|
||
|
|
@@ -271,11 +365,12 @@ export async function materializeToFs( | |
|
|
||
| // Record blob hash at materialization time for conflict detection | ||
| mount.materializedHashes.set(relativePath, blobHash); | ||
| mount.refusedPaths.delete(relativePath); | ||
| } | ||
|
|
||
| mount.lastMaterializedSeq = currentSeq; | ||
|
|
||
| return written; | ||
| return { written, unchanged, skipped }; | ||
| } | ||
|
|
||
| /** | ||
|
|
||
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.
lastMaterializedBranchIdonly when a file was written, so the mount remains unpinned even though materialization completes. A later divergent branch then bypasses the branch guard and can overwrite those files.Prompt To Fix With AI