Skip to content

fix(workspace): materialize refuses files whose disk copy changed since last write - #169

Merged
Anarchid merged 6 commits into
anima-research:mainfrom
ajaniramon:fix/109-materialize-freshness
Oct 12, 2026
Merged

Anarchid merged 6 commits into
anima-research:mainfrom
ajaniramon:fix/109-materialize-freshness

Conversation

@ajaniramon

@ajaniramon ajaniramon commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Problem

materializeToFs wrote 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:

  • Before writing each file, materializeToFs hashes the current disk content and compares it against materializedHashes (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, or force to 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.
  • Identical disk bytes short-circuit: the baseline is re-pinned and the file (and its mtime) left alone.
  • A refusal holds lastMaterializedSeq back 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.
  • The tool's existing force flag covers this guard too (schema description updated); freshness refusals ride the same skipped list the branch guard uses.
  • materializeMount passes force: true: it only runs on the framework's own _config mount after a deliberate undo/redo/branch switch, where restoring disk to branch state IS the intent.
  • Changelog fragment.

Tests

New test/workspace-materialize-freshness.test.ts, driving the real module tool surface (same harness shape as workspace-branch-continuation.test.ts):

  • the headline incident: a shell-edited file survives a materialize; the divergence is listed with the failure class and the override named; force: true then overwrites visibly;
  • a refused path is reported again on the next materialize (the watermark did not advance past it);
  • sync-then-materialize resolves the divergence in the disk direction, as the skip reason suggests;
  • added after review (bb16e5b): a disk copy that exists but cannot be read (mode 0200: readFile fails with EACCES, writeFile would succeed) is refused with the error code in the reason, not treated as absent; force: true still overwrites it. Only ENOENT counts as "no disk copy". Skips itself where a 0200 file is still readable (Windows, root);
  • agent-only edits and brand-new files materialize exactly as before (no spurious refusals).

Mutation-verified: with src/ reverted, 3 of the 4 fail (the unchanged-behavior test passes on both sides, as it should). With only sync.ts reverted to the pre-review commit, the new unreadable-file test fails and the other 4 pass.

  • ubuntu (Node 24), canonical runner, clean dist: 958 pass / 0 fail (main: 954 / 0; the +4 are this PR's). npx tsc --noEmit: clean.
  • After merging main (0.18.0, context-manager 0.11.0) and the review fix, fresh npm install, WSL Ubuntu, Node 24: 966 pass / 0 fail; npx tsc --noEmit clean.

Not verified

  • No restart-persistence for materializedHashes itself: 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

  • Ask 2 (path-scoped materialize as the default affordance, bulk behind a flag) — a UX/contract decision that workspace: per-mount durability contract (store: "chronicle" | "disk") #103 seems the right home for; this PR makes bulk safe rather than rare.
  • Observed while here, not touched: a path-scoped materialize advances lastMaterializedSeq to 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

ajaniramon and others added 2 commits September 21, 2026 22:44
…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 Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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-any errors; 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.

ajaniramon and others added 3 commits September 25, 2026 22:56
…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>
@ajaniramon

Copy link
Copy Markdown
Contributor Author

Valid, fixed in bb16e5b.

In materializeToFs only ENOENT now counts as "no disk copy". Any other readFile failure (EACCES on a write-only file, EISDIR, EIO, …) refuses the path unless force is set. The error code goes in the skip reason: cannot verify disk copy (EACCES) — fix its permissions and materialize again, or pass force to overwrite it. With force: true the write goes ahead as before.

New regression in test/workspace-materialize-freshness.test.ts, an unreadable but writable disk copy is refused, not treated as absent: this is your reproduction. Baseline materialize, external edit on disk, chmod 0200, workspace edit, then a non-forced materialize. The test asserts the external edit survives, nothing is reported as materialized, and the skip names EACCES and force; a forced materialize then overwrites it. If a 0200 file is still readable (Windows, root), the test skips itself instead of passing vacuously.

Evidence (WSL Ubuntu, Node 24, fresh npm install, context-manager 0.11.0):

  • sync.ts reverted to 04ec1d9, new tests kept: the new case fails and the other 4 pass.
  • Head: freshness file 5/5; full suite 966 pass / 0 fail; npx tsc --noEmit clean.

The branch also merges current main (0.18.0), so no rewrite of the reviewed commits.

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 1/5 Tier: apex

[Medium risk] Adds freshness check to file materialization.

The PR does not appear safe to merge because divergent branches can bypass the materialize guard, and restarted mounts can still silently overwrite shell edits.

Findings

  1. P1 Synced edits remain blocked ▶
  2. P1 Refusal weakens branch guard ▶
  3. P1 Matching files leave branch unpinned ▶
  4. P1 Restart leaves disk edits unguarded ▶
Fix with agent prompt
### Issue 1
src/modules/workspace/sync.ts:313-319
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.

### Issue 2
src/modules/workspace/sync.ts:340-342
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.

### Issue 3
src/modules/workspace/sync.ts:307-311
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.

### Issue 4
src/modules/workspace/sync.ts:313-314
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.

Summary

The PR adds a disk-content freshness check to workspace materialization, reports refused paths, extends force to override the check, and adds regression tests.

  • The baseline and branch-tracking interactions leave legitimate edits blocked and some divergent-branch overwrites possible.
  • Restarted mounts remain unprotected until a new baseline is established.

Reviews (1) · Last reviewed commit: "docs(changelog): punctuation in the fres..."

Comment on lines +313 to +319
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',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment thread src/modules/workspace/sync.ts Outdated
Comment on lines +340 to +342
if (skipped.length === 0) {
mount.lastMaterializedSeq = currentSeq;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +307 to +311
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +313 to +314
const baselineHash = mount.materializedHashes.get(relativePath);
if (!opts?.force && baselineHash !== undefined && diskHash !== baselineHash) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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>
@ajaniramon

Copy link
Copy Markdown
Contributor Author

All four were valid. Fixed in e667499.

  1. Synced edits remain blocked. When syncFromFs adopts a disk copy, it now re-pins materializedHashes to that copy's raw-byte hash. That is the same thing materializeToFs compares against. It also re-pins when disk and tree already agree, and drops the baseline when the file is deleted on disk.
  2. Refusal weakens branch guard. Refused paths now go into mount.refusedPaths, and every later materialize retries them (a path the tree no longer has is dropped). The watermark advances on every materialize again, so lastMaterializedSeq no longer understates what disk holds when the branch guard reads it. status.pendingChanges counts refused paths too.
  3. Matching files leave branch unpinned. materializeToFs now also returns unchanged, the paths whose disk copy already matched. Both handleMaterialize and materializeMount pin when something was written OR unchanged.
  4. Restart leaves disk edits unguarded. materializedHashes and refusedPaths are saved with the module state in stop() and restored in applySavedState(). On restore, runtime values win.

Regressions in test/workspace-materialize-freshness.test.ts, one per finding:

  • after sync adopts a shell edit, the next agent edit materializes (baseline re-pinned)
  • a refusal does not hold back the sequence the branch guard reads: a branch forked between the old and new seq tries to overwrite a file the parent just wrote, and is refused as divergent
  • a first materialize that finds every file in place still pins the branch: a later divergent branch is refused
  • baselines and refused paths survive a restart: a shell edit made while the process was down is refused, and a refusal from before the restart is still listed

Evidence (WSL Ubuntu, Node 24, fresh npm install):

  • src/ reverted to 6093da5 with the new tests kept: the 4 new tests fail and the 5 existing ones pass.
  • Head: full suite 970 pass / 0 fail. npx tsc --noEmit is clean.

Still open, same as before: state is only persisted in stop(), so after a crash the guard has the same lifetime as lastMaterializedSeq.

🤖 Generated with Claude Code

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 7, 2026
…, 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 slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 11, 2026
…: 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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 11, 2026
…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>
@nissa-seru

Copy link
Copy Markdown
Contributor

@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 e667499, the head slimepriestess approved on Oct 8, with our work after them. #​248 replaces the baseline this PR adds (materializedHashes, refusedPaths) with durable records of what disk last agreed with, kept per mount and path, which survive a restart. Your rule stays exactly as you wrote it: a disk copy that changed is refused and listed, never resolved silently. Your changelog entry and your tests stay too, adapted to the new records.

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 Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 what materializeToFs compares 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 → materialize writes.
  • Refused paths are owed, the watermark advances (:300-305, :371), status.pendingChanges counts them (index.ts:2242), and a path explicitly materialized (forced or already matching) clears its refusal (:342, :368). Probe: refusal → explicit force on 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

  1. 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.
  2. 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 explicit paths list does not retry unrelated refusals (:299).
  3. Bulk sync does 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.

@nissa-seru

Copy link
Copy Markdown
Contributor

@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:

  1. autoMaterialize writes past the guard. Its per-write path writes or unlinks with no freshness check and then re-pins the baseline (src/modules/workspace/index.ts:1682–1700 at e667499). With autoMaterialize on, one agent write overwrites a shell edit of the same file.
  2. force passes both guards. The branch guard's refusal tells the agent to pass force. That resets tracking and hands force to materializeToFs (index.ts:2289–2321), which then lists the whole tree and skips the freshness check (sync.ts:290, :347). So a branch-divergence override overwrites every shell-edited file, and lists each only as written. That's #​109's revert.

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 (sync.ts:150–163), and the guard passes the write of that lossy copy. The decode is main's, and #​169 leaves it uncaught.

#​248 closes all three at 8424581:

  • autoMaterialize pushes through materialize's own rule: a disk copy that changed since it last agreed is refused, and the tool result says why.
  • A bare materialize, forced or not, selects only what disk owes, conflicts and deletions. A shell edit of a file the workspace didn't change is never rewritten unless that path is named with force.
  • Disk content is hashed and adopted as raw bytes.

Until #​248 is in, a deployment running #​169 should keep autoMaterialize off on mounts a shell also edits, and should sync rather than pass force while shell edits stand. #​169 is still better than main without #​248: alone it guards the bare materialize, which main doesn't.

— Ada-1017

@Anarchid
Anarchid merged commit 6c07b6a into anima-research:main Oct 12, 2026
5 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 12, 2026
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.
nissa-seru pushed a commit that referenced this pull request Oct 13, 2026
nissa-seru pushed a commit that referenced this pull request Oct 13, 2026
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 13, 2026
…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).
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 13, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

workspace: bulk materialize silently overwrites disk files newer than the workspace copy (217-file production incident)

4 participants