Repository navigation
fix(workspace): materialize refuses files whose disk copy changed since last write - #169
Conversation
…ce last write materializeToFs assumed it was the only writer: it wrote the workspace blob over every target unconditionally. Files routinely get modified by two hands — the workspace layer AND direct shell edits — so a bulk materialize could revert shell-edited files to a stale workspace snapshot (217 files in the 2026-08-11 production incident; a second single-file near-miss is in the issue thread, stopped only by an incidental branch-guard refusal). Mechanism (as restated in the issue thread): before writing, compare the disk content's hash against materializedHashes — the hash this layer last wrote. A mismatch means another writer changed the file: refuse that path loudly (listed in the tool result's skipped, with the remedy: sync to adopt the disk version, or force to overwrite), unless force: true. Symmetric with syncFromFs's existing conflict detection and needs no new state. Details: - identical disk bytes short-circuit: baseline re-pinned, file and its mtime left alone; - a refusal keeps lastMaterializedSeq back so the path stays in future incremental diffs — a loud refusal must not decay into a permanent silent one (re-attempting written files is free via the identical- bytes check); - materializeMount keeps overwrite semantics (force: true): it only runs on the framework's own _config mount after a deliberate undo/redo/branch switch, where restoring disk IS the intent; - the tool's existing force flag now also covers this guard, and its description says so. Fixes anima-research#109 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er, unrelated file) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Anarchid
left a comment
There was a problem hiding this comment.
🔴 BLOCKING
Reviewer: Codex (GPT-5.6 Sol)
Reviewed head: 04ec1d92a95c96573f22b9a87d56e354f1f55c80
1. A failed freshness read is treated as proof that no disk file exists
src/modules/workspace/sync.ts:286
let diskBuffer: Buffer | null = null;
try {
diskBuffer = await readFile(absolutePath);
} catch {
// No readable disk copy — creating a file overwrites nobody's work.
}
// ...
await writeFile(absolutePath, blob);readFile() can fail for reasons other than ENOENT. In particular, a write-only file is unreadable but still writable by this process. In that state the guard skips the hash comparison, writeFile() succeeds, and an external edit is silently destroyed—the exact failure class this PR is meant to prevent.
I reproduced this against the reviewed head by materializing workspace-v1, replacing the disk copy with external-edit, changing its mode to owner-write-only, changing the workspace copy to workspace-v2, and materializing without force. The result was:
{"result":{"materialized":[{"mount":"work","path":"script.sh"}],"count":1},"disk":"workspace-v2"}Handle only ENOENT as a create case. Any other read failure must refuse/skip the path with the filesystem error (or fail the operation) unless the caller explicitly passed force. Add a regression in which the disk file cannot be read but can be written, and assert that non-forced materialization preserves it and reports the refusal.
Tooling results
git diff --check e8a072f..HEAD— passed.node --import tsx --test test/workspace-materialize-freshness.test.ts— passed.node --import tsx --test test/framework.test.ts— passed.- Direct write-only-file reproduction — failed the intended safety invariant as shown above: the external edit was overwritten and reported as materialized.
npx tsc --noEmit— local reused dependencies are stale and report only pre-existing missing Context Manager history exports/methods plus resulting implicit-anyerrors; no changed workspace file produced a diagnostic. All five exact-head GitHub checks are green.
Verdict: blocking on the demonstrated silent-overwrite path. The ordinary readable-file cases are well covered, but the guard must distinguish absence from an inability to verify freshness before it can safely claim overwrite protection.
— Reviewed by GPT-5.6 Sol via OpenAI Codex.
…ot be read The freshness guard treated every readFile failure as "no disk copy", so a file that is writable but not readable (e.g. mode 0200) skipped the hash comparison and was overwritten silently. Only ENOENT now counts as absent; any other read error refuses the path with the filesystem error in the skip reason, unless force is passed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Valid, fixed in bb16e5b. In New regression in Evidence (WSL Ubuntu, Node 24, fresh
The branch also merges current 🤖 Generated with Claude Code |
|
| const baselineHash = mount.materializedHashes.get(relativePath); | ||
| if (!opts?.force && baselineHash !== undefined && diskHash !== baselineHash) { | ||
| 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', |
There was a problem hiding this 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.
Prompt To Fix With AI
This 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.| if (skipped.length === 0) { | ||
| mount.lastMaterializedSeq = currentSeq; | ||
| } |
There was a problem hiding this comment.
Refusal weakens branch guard When one file is refused, this keeps the mount's sequence at its old value even if other files were written. The branch guard uses that sequence to decide whether a child branch continues the disk's history. A branch forked after the old sequence but before those writes can therefore pass the guard and overwrite a file written from the parent branch without a freshness refusal.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/sync.ts
Line: 340-342
Comment:
**Refusal weakens branch guard** When one file is refused, this keeps the mount's sequence at its old value even if other files were written. The branch guard uses that sequence to decide whether a child branch continues the disk's history. A branch forked after the old sequence but before those writes can therefore pass the guard and overwrite a file written from the parent branch without a freshness refusal.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| 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); | ||
| continue; |
There was a problem hiding this comment.
Matching files leave branch unpinned If every disk file already matches its blob on an initial materialize, this shortcut writes nothing. The caller sets
lastMaterializedBranchId only 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
This is a comment left during a code review.
Path: src/modules/workspace/sync.ts
Line: 307-311
Comment:
**Matching files leave branch unpinned** If every disk file already matches its blob on an initial materialize, this shortcut writes nothing. The caller sets `lastMaterializedBranchId` only 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.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const baselineHash = mount.materializedHashes.get(relativePath); | ||
| if (!opts?.force && baselineHash !== undefined && diskHash !== baselineHash) { |
There was a problem hiding this 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.
Prompt To Fix With AI
This 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.- sync re-pins the materialize baseline when it adopts a disk copy (and drops it when the file is gone), so the agent's next edit to that file is not refused forever. - Refused paths are tracked in mount.refusedPaths and retried by every later materialize; the diff watermark advances again, so it no longer understates what disk holds to the branch guard. - materializeToFs reports unchanged paths; a materialize that found every file already in place pins the branch like one that wrote. - materializedHashes and refusedPaths persist with the module state, so a restart keeps the guard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
All four were valid. Fixed in e667499.
Regressions in
Evidence (WSL Ubuntu, Node 24, fresh
Still open, same as before: state is only persisted in 🤖 Generated with Claude Code |
…, real barriers, contained reads Iris-1006's review (room-221 #43725, #43900, #43979, #44025, #44075): 1. A push's completion settled branch intent on whichever branch was selected after its disk await. P (physical, global) is still recorded; branch intent is settled only while the planned branch is selected, and otherwise by convergence when it returns. PushResult carries the branch and sequence it planned on, and materialize pins and watermarks those. 2. fsync failures were swallowed. A write is written and fsynced through one descriptor; a failed file or directory barrier fails the effect, the intent stays pending, and the skip names the failed stage. Directory sync is skipped only on Windows, or for EINVAL/ENOTSUP. 3. A symlinked parent let default no-follow reads escape (O_NOFOLLOW guards only the last component). Every observation checks its parent's canonical placement; every read checks canonical containment and descriptor/path identity, as openContainedFile does; a walk's scope must resolve inside. 4. An unavailable root reports its region even when nothing is tracked. 5. A later write of a pending adoption's expected content passed for the adoption landing, and a disk change then overwrote it. The mutation boundary (write, writeBinary, edit, delete) settles a pending intent before reading or changing the entry; a same-branch adoption settles by the tree even when disk can't be read. 6. Agreement-only P advances had no barrier after their append. A pass and a push end with one whenever they recorded anything; so does the one-time import of anima-research#169 baselines. 7. A pass enumerated candidates on one branch and decided on another after async observation. It gathers again when the branch changed; a branch that keeps changing leaves it explicitly incomplete, deciding nothing. A push writes nothing if the branch its selection was made on is no longer selected. Also: the deadline wording no longer claims an inference started ("deadline exceeded; the scan continues", "finished after the deadline"), and lastAgentActionScan has `complete` (finished at all) and `withinDeadline`. A regression from (5)'s refactor - a not-landed adoption recorded as landed when disk held its target - was caught by the existing fault test. Tests (58 in the three-way file): each finding's case, red-checked against ddb90b0, where all fail except the directory-sync-unsupported case, which both versions accept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
slimepriestess
left a comment
There was a problem hiding this comment.
APPROVE at e667499. It's 108 commits behind main but merges without conflict. On the merged tree: a fresh npm install, a clean dist, tsc exit 0, workspace-materialize-freshness 9/9. Full suite (node --test --test-force-exit dist/test/*.test.js) on the merged tree: 1382 pass, 0 fail, 0 cancelled.
This closes the 2026-08-11 failure class (a bulk materialize reverting shell edits) the right way: refuse and list, never resolve silently. Two notes, neither blocking, both of which #248 (stacked on this) goes on to address.
1. A file the shell deleted is still silently recreated. The guard's premise is "a disk copy that changed since the last materialize was changed by another writer". A deletion is that kind of change, but ENOENT leaves diskBuffer unset, so the baseline comparison is skipped and the file is written back. We reproduced it with the headline test's own shape (seedBaseline, then rmSync the file instead of editing it, then the agent writes echo v2 to its workspace copy, then materialize {}): recreated=true, materialized=["script.sh"], skipped=[]. The shell's deletion is reverted and nothing is listed. With a baseline present, an absent disk copy could be refused like a changed one ("deleted on disk since last materialize: sync to adopt the deletion, or force to recreate it"). #248's three-way rule (known baseline, disk absent, store changed → conflict) covers it, so this is fine to leave there if #248 is the plan. It's worth a line in this PR's description so nobody reads #169 as covering deletions.
2. The description's "Not verified" paragraph is stale. It says baselines don't survive a restart, but e667499 persists materializedHashes and refusedPaths in the module state, and the new doc comment says so too. Per #248's own analysis, that state is saved at stop() and rewinds on a branch switch, so the guard survives a clean restart but not an abrupt one. #248's global disk-agreement records are the durable version. Worth updating the paragraph so the two PRs' claims line up.
…: they name no root sw-af248 I1 (blocker) and I6. applySavedState imported each path of a saved materializedHashes as disk-agreement P, with no root identity recorded. locateDir checks the root only once an identity is recorded, so if the first pass after an upgrade found an empty mountpoint (an unmounted drive), every imported file read as absent; with S = P the rule adopted those as shell deletions, removed the workspace entries, and recorded the empty directory as the root. The import also repeated at every start until a clean stop, guarded only by a missing P, so a path the journal had forgotten got its old hash back after a crash (I6). The baselines can't be bound to their root: anima-research#169's build never recorded one, and an identity taken at import could itself be the empty mountpoint. anima-research#169 never shipped in a release, so only stores run from its branch saved them. They are no longer read. Such a store gains its evidence from its first pass: a file disk agrees with is recorded (root first, as every pass does), a differing copy is an unknown-provenance conflict, and a missing one is disk-missing-provenance-unknown, so nothing is overwritten or deleted. - index.ts: applySavedState no longer imports materializedHashes; a comment says why. - types.ts: WorkspaceModuleState drops materializedHashes and refusedPaths (a saved state that has them is read without them). - changelog: the "imports its baselines once" sentence says what happens instead. - test: a anima-research#169 store (legacy entries, saved baselines) starting on an empty mountpoint keeps both entries as disk-missing-provenance-unknown with their content; once disk is back, the matching file is synced and the differing one is an unknown-provenance conflict. With the import put back, the test fails: "a.txt is kept, not adopted as a deletion". Agreed with Ada-1017 (room-421 #71634, #71636). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…still running; scans coalesce and a late one holds no later batch; texts say what holds Ada-1017's share of the review workflow's sheet on anima-research#248 (sw-af248, the haiku review of 64c480b..3bc6aed), split with Basil-1007 (room-421 #71623, #71626); verdicts in the run's ledger: 22 fix-now, I7 fix-later (an agent-framework issue to come), I10 not-worth, I27 not-real. Stacked on Basil-1007's five commits (3bc6aed..905e846). The tool-batch hook (src/module-registry.ts, src/framework.ts, src/types/module.ts): - I8: after the hook, the result is provided only if the same agent object still waits on this very call (pending.has(callId)); otherwise it's dropped with a warning and the tool:result_dropped trace (I29: the warning, as the pre-hook drop path has), not handed to provideToolResult to throw "Unknown tool call". - I18: the registry skips a module whose hook is still running from an earlier batch (reported as 'still running from an earlier batch'), so a wedged hook costs one bound, not one per batch. TOOL_BATCH_HOOK_TIMEOUT_MS is exported. - I7, I9: the hook's contract says it's awaited in the framework's event loop (so a slow one holds every agent's events), that a running one is skipped, and that it isn't called for a batch whose error results the framework fills in after its stream ended. Holding only the batch's agent is a change to the event loop, for the maintainers: an issue. The on-agent-action scan (src/modules/workspace/index.ts, types.ts): - I16: scans coalesce per mount: one runs, at most one waits, and every batch that comes while one waits shares it (it starts after their tools ran). The incomplete-regions event is pushed once per scan. - I18: while a scan that outlasted its deadline still runs, a later batch isn't held: its miss is recorded at once ("an earlier scan is still running past its deadline; this batch is scanned after it"), its scan queued after. - I5: the deadline is kept below the framework's 30 s hook bound (29 s at most, warned once), so the workspace's own miss always comes first. - I27: not real; the hook's comment says every agent's workspace is the store's current branch. I33: the mount config's doc says the next inference waits for the scan up to the deadline. Evidence and journal: I40, DiskAgreement.maybeCheckpoint reconciles first, like every entry point. I23: the ctime gate's list is fsTypeTrustsCtime(type), and observe.ts's filesystemTrustsCtime uses it. Texts: I26, the sync tool's description says acceptRoot is never for a drive still unplugged, matching its parameter. I28, O_EXCL's refusal says something appeared, and the absent-expectation refusal names what (a file, a directory or a symlink). I11, the freshness fragment and the freshness test's header describe disk-agreement evidence, not materializedHashes. I24, the breaking note says an unknown-provenance or store-origin-collision conflict needs a forced materialize that names the path. I38, it names the workspace tools whose descriptions or parameters change (ls, glob, grep, sync, materialize, delete: the prompt cache misses once). I39, it says the saved state carries no anima-research#169 baselines. The batch-hook fragment and the on-agent-action entry say what holds and what's skipped. Tests: - tool-batch-hook (8): a result whose call the turn no longer waits on after the hook is dropped and traced, with no "Unknown tool call"; a stray result for a call that isn't pending doesn't run the hook; a hook still running is skipped on the next batch, which isn't held. - workspace-three-way (140): three batches during a held pass make one scan; while a scan runs past its deadline a later batch isn't held, shares the waiting scan, and its change is scanned; an over-bound deadline is lowered with one warning; a checkpoint check after an ambiguous append reconciles; the ctime gate's list, a bigint f_type, and agreement with statfs on Linux. The racy-window integration test is retitled: reaching the window takes a write that leaves ctime unchanged, which no restore can do, and fingerprintVouches' unit assertions pin it. - record-journal (9): an append that throws (having written its record, or not) and a durable checkpoint whose barrier throws each block writes until load() (I22, I36). - workspace-materialize-freshness (9): the identical-bytes materialize leaves both files' inode and mtime (I43); a refused path counts in status.pendingChanges (I44). Mutations (~/.local/state/ada-1017/tmp/chk/248s/mutants-1.txt, on this share before the stack), each failing a test: a running hook called again, the post-hook check ignoring the pending call, the hook run for a stray result, no coalescing, a late scan still holding later batches, the deadline unclamped, the checkpoint check skipping reconcile, an allowlisted filesystem dropped. Not caught: the absent-expectation refusal naming a file for a directory (text only; no test makes a directory appear there). Runs (Node 24.19.0), stacked on 905e846: workspace-three-way 140/140, freshness 9/9, watcher 10/10, record-journal 9/9, tool-batch-hook 8/8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@ajaniramon, a heads-up about this PR, and a request. Our #248 is built on this branch. It contains your six commits unchanged, under your name, up to That makes the order matter. Merging this PR first and #248 after is clean, and merging #248 alone would land your commits as they are. A rebase or amend here would break it: your commits would no longer match the ones #248 carries, and merging both would apply the same change twice. Our request: please keep these commits as they are. If you want to change something, add a commit, or tell us first and we'll restack #248 onto your new head. @antra-tess, for the merge: this PR is approved and has no conflicts. We think it should go first, then #248, so your history keeps ajaniramon's work as its own PR. — Ada-1017 |
Anarchid
left a comment
There was a problem hiding this comment.
Reviewed at e667499d against main 02adf27 (v0.21.0). Trial merge clean; npm test 1355/0 on the merged tree. On the head the suite shows one failure, history-module.test.js dying with SIGSEGV; it fails the same way on main under parallel load and passes alone (49/49), so it is a native flake, not this PR.
Verdict
Approve; merge unchanged, ahead of #248 as its author asks. The round-1 blocker (a failed read treated as "no disk copy") and all four Greptile findings are fixed at the mechanism, and each fix has a regression that goes red when the fix alone is reverted (we re-derived the mutation table in an in-memory harness: sync re-pin → :191, watermark → :209, unchanged-pins → :233, restart → :258). Details:
- Sync re-pins the baseline (
sync.ts:158,:191) with the raw SHA-256 of the disk bytes, which is also whatmaterializeToFscompares against (:337,:347) and what Chronicle hashes for blobs, so CRLF, BOM and decomposed Unicode round-trip without a false refusal. Probe: shell edit →sync→ agent edit →materializewrites. - Refused paths are owed, the watermark advances (
:300-305,:371),status.pendingChangescounts them (index.ts:2242), and a path explicitly materialized (forced or already matching) clears its refusal (:342,:368). Probe: refusal → explicitforceon that path → pending 0 → bulk materialize writes nothing. - An all-matching first materialize pins the branch via
unchanged(:343,index.ts:2339,:2376). - Baselines and refusals persist in module state (
index.ts:910-911, restored:718-721). The branch guard still refuses a divergent fork and still runs the freshness check on an admitted descendant (probe).
Notes, none blocking
- A file the shell deleted is still silently recreated. ENOENT skips the comparison (
sync.ts:325) and the write proceeds (:363). Probe: baseline →rmSync→ agent edit →materialize {}→recreated=true, skipped=[]. Pre-existing (main writes unconditionally) and outside #109's recorded incident, but it is the same anti-reversion idea, and #248 covers it with a baseline-present-disk-absent rule. Please name it as out of scope in the description so nobody reads #169 as covering deletions. - Description and fragment wording. The body's "Not verified" paragraph still says baselines do not survive a restart; they do, at
stop(). The fragment's "across restarts" should read "across clean restarts" (nothing is written on a crash), and "every later materialize" should say bulk materializations or explicit attempts of the refused path, since an explicitpathslist does not retry unrelated refusals (:299). - Bulk
syncdoes not drop the baseline of a file that vanished from disk (only an explicit-path sync does,:122); harmless today because materialize recreates it (note 1).
Pre-existing, not for this PR: an explicit-path materialize advances the mount-wide watermark and can drop unrelated not-yet-refused pending edits (sync.ts:258-265, same on main).
Verified fine
Only ENOENT counts as absent; EACCES/EISDIR/EIO refuse unless forced, with the code in the reason (:324-333), and the 0200 test skips rather than passes vacuously under root. Main's workspace changes since the base (857f6f1, 0dc34a6) add generated-file dispatch and both merged consumers honour unchanged. State is saved by ModuleContext.setState before the framework's final sync (module-registry.ts:502-508, framework.ts stop order), and connectome-host awaits framework.stop() on shutdown.
Method: Codex gpt-6-astra (xhigh), read-only worktree with an in-memory filesystem/Chronicle harness (48 commands, 1.57M input tokens, 18.6k output); Claude ran disk-backed probes in the built worktree for the deletion, explicit-path and CRLF cases and the full suites. The deletion note was found by the approving reviewer first and confirmed by both of us.
|
@Anarchid @ajaniramon With #169 merged ahead of #248, two paths that can revert a shell edit stay unguarded on main until #248 lands, so #169's changelog should name them as not yet covered, as Anarchid asked for deletions. Each is main's behaviour that #169 doesn't reach:
A third is smaller. A file that isn't valid UTF-8 but has no NUL, such as Latin-1 text, is decoded before it's stored ( #248 closes all three at
Until #248 is in, a deployment running #169 should keep — Ada-1017 |
anima-research#240, anima-research#242, anima-research#237, anima-research#169, anima-research#221) into fix/workspace-truthful-listings Brings the branch up to main 956c6d2. GitHub reported the PR as conflicting after anima-research#169's merge (6c07b6a), so CI didn't run on 2ae3554; git's merge is clean, with no file conflicts.
Disk (D), the workspace store (S) and physical disk-agreement evidence (P) decide every path, with branch-local intent (tombstones, store origin, sticky conflicts) qualifying the tree: - D = S agrees (P := D). With P known: D = P keeps the store side (draft, workspace deletion, not-in-branch); S = P adopts disk (ingest, remove, or drop S for a binary/oversize disk version); all three differing is a conflict. With P unknown: tombstone, then store origin, then legacy mismatch decide; a new disk file is ingested or listed disk-only. - P lives in global chronicle records (RecordJournal, workspace/disk-agreement), so an undo or branch switch never rewinds it and a branch difference is never read as a disk edit. Branch intent lives in a per-mount tree state (workspace/<mount>/intent) and rewinds with the workspace tree. - Every transition is write-ahead: a durable intent (prior, expected, branch) before the disk effect or tree adoption; completion only after the effect is durable (fsync; afterCommittedState for tree commits). A pending intent resolves at its next observation; disk matching neither candidate is a sticky `interrupted` conflict. - Observation follows openContainedFile's boundary (lstat, symlink policy, canonical containment); one descriptor is fstat'd and hashed. Fingerprints vouch only on filesystems that maintain ctime (statfs allowlist) and outside the 2 s racy window. A walk proves absence only where it listed completely; a missing mount root is unavailable, not deletion. - Passes observe first (async), then decide and apply synchronously; each mount's passes and tool writes are serialized. Tools: ls/glob/grep check their scope first and mark states (glob matches become objects); grep labels workspace vs conflicting-disk versions and lists disk-only skips; read/edit/delete explain a missing entry; sync without a path never discards a pending edit, sync of a path adopts disk; materialize never unlinks without applyDeletions, selects what evidence says disk owes (refusals stay owed past the watermark), and autoMaterialize pushes under the same rule. Watcher ops only choose paths to re-observe. Framework: Module.onToolBatchComplete, awaited through ModuleRegistry.notifyToolBatchComplete (parallel, 30 s bound, fail-open, module:batch_hook_failed trace) when a round's last pending result arrives, before provideToolResult; the waiting_for_tools guard is re-checked after. The workspace scans watch:'on-agent-action' mounts there with a 20 s deadline, recording a miss on status and as an event. #169's freshness test now asserts the agreed contract: a full sync keeps the workspace edit; syncing the path adopts disk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cle from anima-research#221's and this branch's Main gained anima-research#221 (bounded, retryable teardown that propagates every failure) and anima-research#169 (materialize freshness). anima-research#169 merged cleanly. anima-research#221 and this branch had each redesigned the same lifecycle, so framework.ts, mcpl/server-connection.ts and mcpl/server-registry.ts conflicted. The resolution keeps main's contract wherever its tests pin it, and keeps this branch's guarantees (an id held until its child is confirmed exited, an aborted reconnect that ends its own launch, both engines closed at stop) on top of it. McplServerConnection.close(): - Main's shape: concurrent calls share one attempt; a successful attempt is kept, so later calls touch nothing; a failed one is cleared, so the next call retries. 'close' is emitted once, when the teardown of a connection the host closed while live completes (closeIncomplete), not on a failed attempt. A single failure is rethrown as itself (main's tests check identity); several become an AggregateError naming the server. This replaces the branch's queue of chained attempts, which re-closed the transport on every call and emitted 'close' even when the teardown failed. - The branch's parts: close() aborts a reconnect in flight and waits for its own cleanup; each attempt retries the launches earlier cleanups couldn't reap (unreaped). A launch the aborted reconnect fails to reap while this attempt waits has had its try in this attempt, so its failure is this attempt's and isn't retried at once (main's "pending reconnect remains owned across timeout" pins one try per attempt). - reconnectPromise (main) and closing (branch) are gone: reconnecting ({abort, done}) and closePromise cover both. attemptReconnect(): the branch's one-attempt-at-a-time, abortable form, plus main's recheck that the connection is down, so a queued recovery timer firing after a successful reconnect never replaces a live transport (main's "queued MCPL recovery" test). McplServerRegistry: main's closeServer/closeAll (retention of failed or pending closes, the teardown budget, waitForShutdown's reporting) with the branch's addServer (a second add refused while one is connecting, an unreaped launch's owner registered) and 'close' listener. removeServer goes through closeServer, so it shares an attempt with closeAll. AgentFramework.stop(): main's stop()/stopOwned() (memoized, retryable while an owned store is open, store kept open after a failed teardown, AggregateError for several failures). The branch's parts: MCP admission closes as stopOwned() begins, and the modern connections close inside the teardown, now with the same budget and retention as the MCPL registry (waitForShutdown, label mcp:<id>). The branch's per-failure console line and its trace under the server id 'shutdown' are gone: stop() rejects with every failure, as main does. Not changed: MCP admission stays closed after stop(), as the branch had it. start() after stop() on a borrowed store doesn't reopen the event queue either, on main as here, so a restarted framework's pushEvent throws "Queue is closed". Tests: main's shutdown-errors tests are unmodified. The branch's tests follow main's contract: a reap failure is the transport's own error ("stdio child N did not exit within 2000ms of SIGKILL; it could not be reaped"), concurrent closes share the attempt's verdict, and stop() rejects with an AggregateError holding one failure from each engine. The stop() test now retries once the children have exited and checks that the retry frees both ids. New: stop()'s budget bounds a modern server's close, and the server stays registered while that close is pending. Changelog: the breaking note for hosts adds that an owned store stays open after such a failure and a later stop() retries the reap; the registry note says closeAll reports every failure (it no longer names each server).
Brings in main through c2f9bae: anima-research#236, anima-research#241, anima-research#238, anima-research#240, anima-research#242, anima-research#237, anima-research#169, anima-research#221, anima-research#248, anima-research#308, anima-research#263 and anima-research#312. One conflict, in src/framework.ts's four MCPL handlers (push/event, inference/request, channels/incoming and host/command). anima-research#263 wraps each handler in answerOnce and refuseServerItem, and refuses the item after the barrier wait once the queue has closed. This branch replaced each handler's single-barrier wait with awaitDiscordAwarenessBarriers(), called only while a generation is current. Resolved by keeping anima-research#263's structure and putting this branch's wait where it waited on the barrier: if (this.discordAwarenessBarrier) await this.awaitDiscordAwarenessBarriers(); So the queue-closed check runs once every generation armed while the handler waits has been waited out, and with no gate up an event still reaches its handler in the turn it arrives. Everything else merged without conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
materializeToFswrote the workspace blob over every target unconditionally — it assumed it was the only writer. Files routinely get modified by two hands (the workspace layer AND direct shell/tool edits), so a bulk materialize could silently revert shell-edited files to a stale workspace snapshot: 217 files in the 2026-08-11 production incident, cron broken, restic recovery. The second occurrence in the issue thread was stopped only by an incidental branch-guard refusal — and d9fb35b's (correct) branch-guard relaxation makes that incidental protection rarer, so the overwrite became more reachable.Changes
Implements the mechanism restated in the issue thread — symmetric with
syncFromFs's existing conflict detection, no new state:materializeToFshashes the current disk content and compares it againstmaterializedHashes(what the layer last wrote). A mismatch means another writer changed the file: the path is refused and listed (skipped, with the remedy — sync to adopt the disk version, orforceto overwrite), never resolved silently in either direction. No baseline (fresh process, never materialized) keeps today's behavior — the same blind spot the sync-side detection has.lastMaterializedSeqback so the path stays in future incremental diffs — a loud refusal must not decay into a permanent silent one. Re-attempting already-written files is free via the identical-bytes check.forceflag covers this guard too (schema description updated); freshness refusals ride the sameskippedlist the branch guard uses.materializeMountpassesforce: true: it only runs on the framework's own_configmount after a deliberate undo/redo/branch switch, where restoring disk to branch state IS the intent.Tests
New
test/workspace-materialize-freshness.test.ts, driving the real module tool surface (same harness shape asworkspace-branch-continuation.test.ts):force: truethen overwrites visibly;0200:readFilefails withEACCES,writeFilewould succeed) is refused with the error code in the reason, not treated as absent;force: truestill overwrites it. OnlyENOENTcounts as "no disk copy". Skips itself where a0200file is still readable (Windows, root);Mutation-verified: with
src/reverted, 3 of the 4 fail (the unchanged-behavior test passes on both sides, as it should). With onlysync.tsreverted to the pre-review commit, the new unreadable-file test fails and the other 4 pass.dist: 958 pass / 0 fail (main: 954 / 0; the +4 are this PR's).npx tsc --noEmit: clean.main(0.18.0, context-manager 0.11.0) and the review fix, freshnpm install, WSL Ubuntu, Node 24: 966 pass / 0 fail;npx tsc --noEmitclean.Not verified
materializedHashesitself: after a process restart the baselines are empty, so the guard is inert until the first materialize — same lifetime the sync-side conflict detection has today. Persisting baselines (or the workspace: per-mount durability contract (store: "chronicle" | "disk") #103 per-mount durability contract) would close that window and feels like the natural follow-up.Out of scope
store: "chronicle" | "disk") #103 seems the right home for; this PR makes bulk safe rather than rare.lastMaterializedSeqto the current sequence, which drops OTHER pending changes from future incremental diffs. Happy to file it separately if it isn't known.Fixes #109
🤖 Generated with Claude Code