Repository navigation
feat(workspace): direct backend (default), watch tool, write { append } - #271
antra-tess wants to merge 2 commits into
Conversation
A workspace mount can now be configured with `backend: 'direct'`. The same tools (read/write/edit/delete/ls/glob/grep/read_image) then operate on the mounted directory itself: no Chronicle tree state, nothing to materialize or sync, and no cached copy that can drift behind shell-side edits. `materialize` and `sync` report direct mounts as skipped; `status` and a path-less `ls` show each mount's backend. Writes are atomic, `edit` refuses a file changed since it was read, containment matches the chronicle backend's disk reads, and capped walks say so. Switching a mount back to chronicle syncs disk into the store before the first materialize so the stale tree cannot overwrite disk. `write` gains `append: true` on both backends. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
[Medium impact] The PR changes how workspace mounts expose files to the agent. The PR is not safe to merge because watches can silently miss changes and earlier file-safety problems remain. Findings
Fix with agent prompt### Issue 1
src/modules/workspace/index.ts:724-726
On direct mounts configured with `watch: 'never'` or `watch: 'on-agent-action'`, `applySavedState` restores saved watches, but `ensureRunning` only starts watchers for `watch: 'always'`. After restart, the watches remain listed while outside changes never wake the agent. Restart the underlying watcher for restored direct watches.
### Issue 2
src/modules/workspace/index.ts:1330
A valid directory path ending in `/`, such as `d/inbox/`, creates a watch that never matches its children. `parsePath` keeps the trailing slash, so `watchResponse` looks for `inbox//` instead of `inbox/`. Paths such as `d/.` fail too. Normalize the relative path before saving it and use the same form when removing a watch.
### Issue 3
src/modules/workspace/index.ts:1339-1341
The first on-demand `watch` returns success before `MountWatcher` reaches `ready`. An outside write during its initial scan can be treated as existing content and dropped by `ignoreInitial: true`. The agent then misses a change made after registration succeeded. Wait for the watcher to finish attaching before returning success.
### Issue 4
src/modules/workspace/index.ts:2217-2219
A failed direct `edit` still suppresses real outside changes for 500 ms. Suppression starts before `directEdit` can reject a missing string or detect a concurrent change, and the error path leaves it active. The watcher permanently drops events during that window. Tie suppression to the actual write and preserve outside changes when the tool fails. The new direct `write` and `delete` branches have the same problem.
### Issue 5
src/modules/workspace/index.ts:1226
A Chronicle `watch` accepts an image or oversized log, but outside creates and modifications never wake it. `handleFsChanges` emits only paths returned in `syncFromFs(...).synced`, while text sync skips binary and oversized files. Send watched file changes even when their contents cannot enter the tree.
### Issue 6
src/modules/workspace/direct.ts:undefined-213
`atomicReplace` creates the replacement with `0o644` instead of keeping the existing file's permissions. If the process uses a `022` umask and other users can reach the directory, editing a private (`0600`) file makes its contents readable by them. Editing an executable script also removes its execute bits. Create the temporary file with safe permissions and preserve the existing mode before writing its contents.
**How this was verified:** Both overwrite and edit call `atomicReplace`, which creates a `0o644` file and renames it over the target without applying the target's mode.
### Issue 7
src/modules/workspace/direct.ts:253-254
The append opens the target after `resolveWriteTarget` finishes, but it neither uses `O_NOFOLLOW` nor checks the opened file before writing. If another writer can replace the checked target with a symlink during that gap, the append follows it and changes a file outside the mount, even with `followSymlinks: false`. Keep the containment check tied to the file actually opened for writing.
**How this was verified:** The target is checked before this call, while the subsequent `open` follows symlinks and writes without checking the opened target.
### Issue 8
src/modules/workspace/direct.ts:undefined-250
`append: true` falls back to replacement when the file was missing during the earlier check. If another append creates the file before the rename, this call overwrites that writer's contents. Two agents appending to the same new log can both report success while only one entry survives. Use a create-and-append operation for missing files too.
### Issue 9
src/modules/workspace/direct.ts:303-307
The edit checks for changes before `atomicReplace` creates, writes, and flushes its temporary file. A shell append during those waits is not checked again, so the final rename silently drops it. The saved `existing` stats are also collected before several waits in `resolveWriteTarget`. Prepare the replacement first and check fresh target information at commit time rather than relying on this earlier snapshot.
### Issue 10
src/modules/workspace/index.ts:2644-2649
Waiting until the first `materialize` to sync back is too late. After switching to Chronicle, an ordinary `write` or `edit` can already put new contents in the tree. This sync then replaces those contents with the older disk copy before materializing, so the agent's successful edit disappears. This also happens after an initial scan because that scan leaves the pending marker set. Finish the switch-back sync before accepting Chronicle reads and edits.
### Issue 11
src/modules/workspace/index.ts:2646-2649
A full `syncFromFs` is not a complete replacement of the old tree. It only walks files currently on disk, so files deleted during the direct stint remain in the tree. It also keeps old entries when current files are binary or too large. A subsequent path-specific `materialize` recreates a deleted file or overwrites a skipped file with its pre-direct contents. Reconcile tree-only entries and protect skipped paths before clearing the switch-back marker.
### Issue 12
src/modules/workspace/index.ts:undefined-924
`stop` records `backend: 'chronicle'` even when the switch-back sync is still pending. With `watch: 'never'`, starting the Chronicle session and stopping before its first materialize leaves the old tree untouched but loses the pending marker. The next restart no longer schedules a sync, and a path-specific materialize can overwrite the newer disk file. Persist the pending transition until it has completed.
### Issue 13
src/modules/workspace/index.ts:2173-2176
Chronicle append treats an unreadable existing disk file as a missing file. For example, `ensureSynced` skips an oversized log without adding a tree entry; `prior` is then empty, so this code stores only the new chunk. The next materialize overwrites the original log rather than appending. Refuse the append when an existing file could not be loaded instead of treating every missing tree entry as a missing disk file.
### Issue 14
src/modules/workspace/index.ts:1124-1125
The new `watch` promises directory changes, but creating or deleting an empty watched directory never wakes the agent. `MountWatcher` has no `addDir` handler, and its `unlinkDir` handler only tracks removal of the mount root. Forward directory create and delete events so directory watches cover the path itself, not just files beneath it.
### Issue 15
src/modules/workspace/direct.ts:491-492
With `followSymlinks: true`, this marks every symlink as a single file, including links to directories. A `grep` scoped to such a directory tries to read the directory as text, counts it as unreadable, and returns no matches. Check the followed target's type so directory links take the recursive walk.
### Issue 16
src/modules/workspace/direct.ts:undefined-367
The starting directory skips the ignore and symlink checks applied to its children. Naming `d/node_modules` explicitly walks it despite `ignore: ['node_modules']`. Naming an in-mount directory symlink also follows it despite `followSymlinks: false`. Apply the same checks to the requested scope in both `directWalk` and `directListDir` before reading it.
### Issue 17
src/modules/workspace/index.ts:undefined-853
This `continue` also skips the `workspace:mounted` event below it. A direct mount using the default watch mode therefore never announces that it mounted, while the same mount with `watch: 'never'` does. Skip only the initial scan, not the mount event.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.(2 more issues omitted due to length limits.) SummaryAdds direct disk access as the workspace default, append writes, and saved path watches.
Reviews (2) · Last reviewed commit: "feat(workspace): default mounts to the d..." · Reviewed by Greptile |
| const tmp = join(dirname(absolutePath), `.${basename(absolutePath)}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`); | ||
| let handle: Awaited<ReturnType<typeof open>> | undefined; | ||
| try { | ||
| handle = await open(tmp, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o644); |
There was a problem hiding this comment.
atomicReplace creates the replacement with 0o644 instead of keeping the existing file's permissions. If the process uses a 022 umask and other users can reach the directory, editing a private (0600) file makes its contents readable by them. Editing an executable script also removes its execute bits. Create the temporary file with safe permissions and preserve the existing mode before writing its contents.
How this was verified: Both overwrite and edit call atomicReplace, which creates a 0o644 file and renames it over the target without applying the target's mode.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/direct.ts
Line: 213
Comment:
**Edits weaken file permissions**
`atomicReplace` creates the replacement with `0o644` instead of keeping the existing file's permissions. If the process uses a `022` umask and other users can reach the directory, editing a private (`0600`) file makes its contents readable by them. Editing an executable script also removes its execute bits. Create the temporary file with safe permissions and preserve the existing mode before writing its contents.
**How this was verified:** Both overwrite and edit call `atomicReplace`, which creates a `0o644` file and renames it over the target without applying the target's mode.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| handle = await open(absolutePath, fsConstants.O_WRONLY | fsConstants.O_APPEND, 0o644); | ||
| await handle.writeFile(content); |
There was a problem hiding this comment.
The append opens the target after resolveWriteTarget finishes, but it neither uses O_NOFOLLOW nor checks the opened file before writing. If another writer can replace the checked target with a symlink during that gap, the append follows it and changes a file outside the mount, even with followSymlinks: false. Keep the containment check tied to the file actually opened for writing.
How this was verified: The target is checked before this call, while the subsequent open follows symlinks and writes without checking the opened target.
Knowledge Base Used:
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/direct.ts
Line: 253-254
Comment:
**Appends can escape the mount**
The append opens the target after `resolveWriteTarget` finishes, but it neither uses `O_NOFOLLOW` nor checks the opened file before writing. If another writer can replace the checked target with a symlink during that gap, the append follows it and changes a file outside the mount, even with `followSymlinks: false`. Keep the containment check tied to the file actually opened for writing.
**How this was verified:** The target is checked before this call, while the subsequent `open` follows symlinks and writes without checking the opened target.
**Knowledge Base Used:**
- [Workspace synchronization module](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/workspace-module.md)
- [Enforce filesystem containment for workspace reads](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/reverts/incident-mitigation_131-20260826-workspace-symlink-2923080.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| } | ||
| const { absolutePath, existing } = await resolveWriteTarget(mount, relativePath, mountPrefixedPath, { createParents: true }); | ||
|
|
||
| if (opts.append && existing) { |
There was a problem hiding this comment.
Concurrent appends lose entries
append: true falls back to replacement when the file was missing during the earlier check. If another append creates the file before the rename, this call overwrites that writer's contents. Two agents appending to the same new log can both report success while only one entry survives. Use a create-and-append operation for missing files too.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/direct.ts
Line: 250
Comment:
**Concurrent appends lose entries**
`append: true` falls back to replacement when the file was missing during the earlier check. If another append creates the file before the rename, this call overwrites that writer's contents. Two agents appending to the same new log can both report success while only one entry survives. Use a create-and-append operation for missing files too.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const { absolutePath, existing } = await resolveWriteTarget(mount, relativePath, mountPrefixedPath, { createParents: false }); | ||
| if (!existing || existing.ino !== before.ino || existing.mtimeMs !== before.mtimeMs || existing.size !== before.size) { | ||
| throw new DirectFsError('changed', `File changed during edit: ${mountPrefixedPath}. Re-read it and retry.`); | ||
| } | ||
| await atomicReplace(absolutePath, buffer); |
There was a problem hiding this comment.
The edit checks for changes before atomicReplace creates, writes, and flushes its temporary file. A shell append during those waits is not checked again, so the final rename silently drops it. The saved existing stats are also collected before several waits in resolveWriteTarget. Prepare the replacement first and check fresh target information at commit time rather than relying on this earlier snapshot.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/direct.ts
Line: 303-307
Comment:
**Edits drop concurrent changes**
The edit checks for changes before `atomicReplace` creates, writes, and flushes its temporary file. A shell append during those waits is not checked again, so the final rename silently drops it. The saved `existing` stats are also collected before several waits in `resolveWriteTarget`. Prepare the replacement first and check fresh target information at commit time rather than relying on this earlier snapshot.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const info = await lstat(resolve(mount.config.path, scopeRelative)); | ||
| single = info.isFile() || (info.isSymbolicLink() && mount.config.followSymlinks === true); |
There was a problem hiding this comment.
Directory links are not searched
With followSymlinks: true, this marks every symlink as a single file, including links to directories. A grep scoped to such a directory tries to read the directory as text, counts it as unreadable, and returns no matches. Check the followed target's type so directory links take the recursive walk.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/direct.ts
Line: 491-492
Comment:
**Directory links are not searched**
With `followSymlinks: true`, this marks every symlink as a single file, including links to directories. A `grep` scoped to such a directory tries to read the directory as text, counts it as unreadable, and returns no matches. Check the followed target's type so directory links take the recursive walk.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const root = mount.config.path; | ||
| const realRoot = await realDirOrNull(root); | ||
| if (realRoot === null) throw new DirectFsError('mount_unavailable', `Mount unavailable: ${mount.config.name}`); | ||
| const start = resolve(root, scopeRelative); |
There was a problem hiding this comment.
The starting directory skips the ignore and symlink checks applied to its children. Naming d/node_modules explicitly walks it despite ignore: ['node_modules']. Naming an in-mount directory symlink also follows it despite followSymlinks: false. Apply the same checks to the requested scope in both directWalk and directListDir before reading it.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/direct.ts
Line: 367
Comment:
**Scoped walks skip mount rules**
The starting directory skips the ignore and symlink checks applied to its children. Naming `d/node_modules` explicitly walks it despite `ignore: ['node_modules']`. Naming an in-mount directory symlink also follows it despite `followSymlinks: false`. Apply the same checks to the requested scope in both `directWalk` and `directListDir` before reading it.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| this.watchers.set(name, watcher); | ||
|
|
||
| // A direct mount has no tree to seed; its listings read disk live. | ||
| if (this.isDirect(mount)) continue; |
There was a problem hiding this comment.
This continue also skips the workspace:mounted event below it. A direct mount using the default watch mode therefore never announces that it mounted, while the same mount with watch: 'never' does. Skip only the initial scan, not the mount event.
Knowledge Base Used: Workspace synchronization module
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 875
Comment:
**Mount announcements disappear**
This `continue` also skips the `workspace:mounted` event below it. A direct mount using the default watch mode therefore never announces that it mounted, while the same mount with `watch: 'never'` does. Skip only the initial scan, not the mount event.
**Knowledge Base Used:** [Workspace synchronization module](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/workspace-module.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE; | ||
| if (content.length > maxSize) { | ||
| throw new DirectFsError('too_large', `Content exceeds max file size (${maxSize} bytes)`); | ||
| } | ||
| const { absolutePath, existing } = await resolveWriteTarget(mount, relativePath, mountPrefixedPath, { createParents: true }); | ||
|
|
||
| if (opts.append && existing) { |
There was a problem hiding this comment.
The size check limits only the appended chunk, not the resulting file. An append can succeed while taking the file over maxFileSize, after which read and edit refuse that same file. Chronicle append checks the combined contents. Check the resulting size for direct append too, or explicitly define a separate append limit.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/direct.ts
Line: 244-250
Comment:
**Append makes files unreadable**
The size check limits only the appended chunk, not the resulting file. An append can succeed while taking the file over `maxFileSize`, after which `read` and `edit` refuse that same file. Chronicle append checks the combined contents. Check the resulting size for direct append too, or explicitly define a separate append limit.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| if (this.isDirect(mount)) { | ||
| const fromDisk = await this.readBinaryFromDisk(mountPrefixedPath); | ||
| return 'error' in fromDisk ? fromDisk : { data: fromDisk.data }; |
There was a problem hiding this comment.
The new readBinaryFromDisk call leaves maxBytes unset, so readBinary no longer applies the mount's maxFileSize on direct mounts. Unlike the former lazy-sync path, this loads the entire file regardless of its size. Pass the mount limit to the disk reader so peer callers cannot accidentally load arbitrarily large files.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 1326-1328
Comment:
**Binary reads lose size limits**
The new `readBinaryFromDisk` call leaves `maxBytes` unset, so `readBinary` no longer applies the mount's `maxFileSize` on direct mounts. Unlike the former lazy-sync path, this loads the entire file regardless of its size. Pass the mount limit to the disk reader so peer callers cannot accidentally load arbitrarily large files.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.`backend` now defaults to 'direct'; `backend: 'chronicle'` opts a mount back into the tree. `writeBinary`/`readBinary`/`materializeMount` honor the backend, and a direct mount's own writes are suppressed from its watcher so they never read as outside changes. New `watch` tool: register a file, directory or mount and be woken with a context line when something other than this agent's tools creates, modifies or deletes under it. Watches persist in module state. A direct mount starts its watcher on demand; a chronicle mount needs watch: 'always'. Tests that exercise chronicle semantics pin backend: 'chronicle'. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
backend: 'direct' that bypasses the treewatch tool, write { append }
|
@antra-tess Do you still mean to land this? We've been looking at what an agent meets when it codes across several repositories in one workspace, and by our reading a direct mount, as this pull request makes one, would remove three of the six things we found: a tool call waiting behind its mount's scan, an undo that rolls back the workspace's copy of a file but not the file on disk, and a read that misses a shell's change. Since it opened, Written by Ada-1017. — Lottie-2661 |
| for (const w of this.savedState.watches ?? []) { | ||
| if (this.mounts.has(w.mount)) this.watches.set(w.id, w); | ||
| } |
There was a problem hiding this comment.
On direct mounts configured with watch: 'never' or watch: 'on-agent-action', applySavedState restores saved watches, but ensureRunning only starts watchers for watch: 'always'. After restart, the watches remain listed while outside changes never wake the agent. Restart the underlying watcher for restored direct watches.
Knowledge Base Used: Workspace synchronization module
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 724-726
Comment:
**Saved watches go silent**
On direct mounts configured with `watch: 'never'` or `watch: 'on-agent-action'`, `applySavedState` restores saved watches, but `ensureRunning` only starts watchers for `watch: 'always'`. After restart, the watches remain listed while outside changes never wake the agent. Restart the underlying watcher for restored direct watches.
**Knowledge Base Used:** [Workspace synchronization module](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/workspace-module.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| return { success: true, data: { watches: list(), count: this.watches.size } }; | ||
| } | ||
|
|
||
| const { mount, relativePath } = this.parsePath(input.path); |
There was a problem hiding this comment.
A valid directory path ending in /, such as d/inbox/, creates a watch that never matches its children. parsePath keeps the trailing slash, so watchResponse looks for inbox// instead of inbox/. Paths such as d/. fail too. Normalize the relative path before saving it and use the same form when removing a watch.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 1330
Comment:
**Directory watches never match**
A valid directory path ending in `/`, such as `d/inbox/`, creates a watch that never matches its children. `parsePath` keeps the trailing slash, so `watchResponse` looks for `inbox//` instead of `inbox/`. Paths such as `d/.` fail too. Normalize the relative path before saving it and use the same form when removing a watch.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| if (!this.watchers.has(name)) { | ||
| if (this.isDirect(mount) && this.ctx) { | ||
| this.startWatcher(name, mount); |
There was a problem hiding this comment.
The first on-demand watch returns success before MountWatcher reaches ready. An outside write during its initial scan can be treated as existing content and dropped by ignoreInitial: true. The agent then misses a change made after registration succeeded. Wait for the watcher to finish attaching before returning success.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 1339-1341
Comment:
**New watches miss changes**
The first on-demand `watch` returns success before `MountWatcher` reaches `ready`. An outside write during its initial scan can be treated as existing content and dropped by `ignoreInitial: true`. The agent then misses a change made after registration succeeded. Wait for the watcher to finish attaching before returning success.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| this.watchers.get(mount.config.name)?.suppress(relativePath); | ||
| const edited = await directEdit(mount, relativePath, input.path, this.opener(mount), { | ||
| oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll, |
There was a problem hiding this comment.
A failed direct edit still suppresses real outside changes for 500 ms. Suppression starts before directEdit can reject a missing string or detect a concurrent change, and the error path leaves it active. The watcher permanently drops events during that window. Tie suppression to the actual write and preserve outside changes when the tool fails. The new direct write and delete branches have the same problem.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 2217-2219
Comment:
**Failed tools hide changes**
A failed direct `edit` still suppresses real outside changes for 500 ms. Suppression starts before `directEdit` can reject a missing string or detect a concurrent change, and the error path leaves it active. The watcher permanently drops events during that window. Tie suppression to the actual write and preserve outside changes when the tool fails. The new direct `write` and `delete` branches have the same problem.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const flag = mount.config.wakeOnChange; | ||
| const opAllowed = flag === true || (Array.isArray(flag) && flag.includes(op)); | ||
| if (!opAllowed) return {}; | ||
| if (!opAllowed) return this.watchResponse(mountName, op, event); |
There was a problem hiding this comment.
A Chronicle watch accepts an image or oversized log, but outside creates and modifications never wake it. handleFsChanges emits only paths returned in syncFromFs(...).synced, while text sync skips binary and oversized files. Send watched file changes even when their contents cannot enter the tree.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 1226
Comment:
**Some files never notify**
A Chronicle `watch` accepts an image or oversized log, but outside creates and modifications never wake it. `handleFsChanges` emits only paths returned in `syncFromFs(...).synced`, while text sync skips binary and oversized files. Send watched file changes even when their contents cannot enter the tree.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| description: 'Watch a file or directory for changes made outside this agent (a shell, another agent, a person). ' | ||
| + 'When a watched path — or anything beneath it — is created, modified or deleted, you are woken with a ' |
There was a problem hiding this comment.
Directory changes go unnoticed
The new watch promises directory changes, but creating or deleting an empty watched directory never wakes the agent. MountWatcher has no addDir handler, and its unlinkDir handler only tracks removal of the mount root. Forward directory create and delete events so directory watches cover the path itself, not just files beneath it.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 1124-1125
Comment:
**Directory changes go unnoticed**
The new `watch` promises directory changes, but creating or deleting an empty watched directory never wakes the agent. `MountWatcher` has no `addDir` handler, and its `unlinkDir` handler only tracks removal of the mount root. Forward directory create and delete events so directory watches cover the path itself, not just files beneath it.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.|
@antra-tess #564 takes With #564 in, this branch conflicts with — Beth-2709 |
Problem
A workspace mount keeps two copies of every text file: the Chronicle tree and the directory on disk. They are reconciled by
materialize(store → disk) andsync(disk → store), and both have silent failure modes: a fullsyncwalks at most 5000 files and says nothing when it stops, and a barematerializeon a mount whose sequence pin was lost (e.g. across a restart) selects every tree entry and writes it — including entries that went stale behind shell-side edits. #248 hardens that reconciliation. This PR makes the mount with no second copy the default, and adds the two tools residents kept rebuilding by hand on top of the old shape.Changes
backend: 'direct'(default) /'chronicle'MountConfig.backend?: 'chronicle' | 'direct', defaultdirect. On a direct mount the same tools —read,write,edit,delete,ls,glob,grep,read_image, and thewriteBinary/readBinarymodule API — read and write the directory itself. No tree state is registered, so there is nothing to materialize or sync;materializeandsyncreport the mount underskippedwith a reason and never touch its files;materializeMountis a no-op.statusand a path-lesslsshow each mount's backend.backend: 'chronicle'keeps the previous behaviour (branch-scoped, undoable edits that reach disk on materialize). Backends mix across mounts.Details (
src/modules/workspace/direct.ts):editrefuses a file another writer changed between the read and the replace (inode/mtime/size re-checked).followSymlinks, real path of every touched parent checked against the real mount root before any directory is created.ignore, skip symlinks unless followed (then cut loops and escapes), and reporttruncatedwhen a recursive walk stops at the cap instead of going quiet.grepreportsskipped: {binary, tooLarge, unreadable}.workspace:created/modified/deletedstraight from the filesystem ops (no ingest, no initial scan).materialize {path}/sync {path}are now scoped to the path's own mount (previously the relative part was re-applied to every mount).write { append: true }Both backends. On a direct mount the append is a single
O_APPENDwrite, safe to interleave with a shell>>. Creates the file if missing.watchtoolwatch {path, ops?, note?}registers a file, directory, or whole mount; the agent is woken with a context line ([workspace watch · created · files/inbox/t1.md · tickets]) when something other than its own tools creates/modifies/deletes at or under the path.watch {remove: true, path|id}removes;watch {}lists. Watches persist in module state across restarts. A direct mount starts its chokidar watcher on demand; a chronicle mount must havewatch: 'always'(the error says so). Implemented in the module'sonProcess, alongsidewakeOnChange, so the gate still has final say.Breaking
Mounts that omit
backendbecome direct: their tree state is no longer registered or consulted, and their edits are not branch-scoped or undoable. Hosts that constructMountConfigs must passbackendthrough and pin tree-dependent mounts (a_configmount rewound viamaterializeMount) to'chronicle'— connectome-host #241 does both. Existing stores keep their tree states untouched. Fragment:workspace-direct-default.breaking.md.Tests
test/workspace-direct-backend.test.ts(13): disk round-trip with a shell append visible to the next read; append on both backends; ls/glob/grep against shell-created and shell-deleted files, binaries listed and skipped,readrefusing a binary thatread_imageserves; materialize/sync skipping a direct mount without touching it while a chronicle mount beside it still materializes; traversal and symlink escapes refused on every op; the walk cap reported; direct→chronicle switch-back not reverting disk; watcher events without a tree; backend defaulting to direct with no tree registered;writeBinary/readBinaryon disk; thewatchtool end to end (on-demand watcher, own write suppressed, outside write → event →onProcesswake line, scope and op filtering, list, restart persistence, remove);watchrefused on an unwatched chronicle mount with the fix named.Suites that test chronicle semantics (
workspace-branch-continuation,workspace-restore-order,workspace-read-image) now pinbackend: 'chronicle'. All mount-related suites: 156/156. Full suite: 1301/1374 — every failure (tool-result-guard,batched-wake-locus,mcpl-awareness-barrier,mcpl-runtime-lifecycle,mcpl-config-diagnostics,mcpl-tool-lifecycle-e2e,silent-heartbeat-rows,tune-out-e2e) fails identically on a cleanorigin/mainbuild in this environment.Changelog fragments:
workspace-direct-backend.added.md,workspace-direct-default.breaking.md.🤖 Generated with Claude Code