Repository navigation
On a mount that follows symlinks, one file reached through two link spellings is two workspace entries, and edit_many stores two diverging copies #631
Description
On a mount with followSymlinks: true, a path through a symlink to one of the mount's own directories is a second workspace key for the file it reaches. The workspace keeps a copy under each spelling. An edit_many call that names the file once through each spelling stores two diverging copies, and disk takes only one of them.
A run at main 7e09923d (Linux, Node 24.22.0) used a mount work with followSymlinks: true and autoMaterialize: true, holding real/notes.txt ("alpha\nbeta\n") and link, a symlink to real:
read work/real/notes.txtandread work/link/notes.txteach return the file.edit_manywith two edits,alphatoALPHAinwork/real/notes.txtandbetatoBETAinwork/link/notes.txt, answers that every edit is stored and that disk took 1 of the call's 2 files: "work/link/notes.txt: not written: disk changed since it was checked".- The workspace then holds "ALPHA\nbeta\n" under
real/notes.txtand "alpha\nBETA\n" underlink/notes.txt. Disk holds "ALPHA\nbeta\n". statuscounts two files and one pending change.
So the BETA edit is stored only in the second copy, and disk holds only the ALPHA edit.
The probe and its output
// Probe (Ruth-2424): one file reached through two spellings on a mount that follows
// symlinks: `real/notes.txt` and `link/notes.txt`, where `link` is a symlink
// to the mount's own directory `real`. What read, edit_many and the push to
// disk do with the two spellings.
// Run with tsx from a directory beside an agent-framework checkout's src.
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync, lstatSync, readlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { JsStore } from '@animalabs/chronicle';
import { WorkspaceModule } from '../src/modules/workspace/index.js';
import type { ToolResult } from '../src/types/events.js';
console.log(`node ${process.version}, ${process.platform}`);
const root = mkdtempSync(join(tmpdir(), 'probe-'));
const log = (line: string) => console.log(line.split(root).join('<tmp>'));
const dir = join(root, 'mount');
mkdirSync(join(dir, 'real'), { recursive: true });
writeFileSync(join(dir, 'real', 'notes.txt'), 'alpha\nbeta\n');
symlinkSync('real', join(dir, 'link'));
log(`mount ${dir}: real/notes.txt = ${JSON.stringify(readFileSync(join(dir, 'real', 'notes.txt'), 'utf8'))}; link -> ${readlinkSync(join(dir, 'link'))} (${lstatSync(join(dir, 'link')).isSymbolicLink() ? 'symlink' : '?'})`);
const store = JsStore.openOrCreate({ path: join(root, 'store') });
const workspace = new WorkspaceModule({
mounts: [{ name: 'work', path: dir, mode: 'read-write', watch: 'never', autoMaterialize: true, followSymlinks: true }],
});
workspace.initStore(store);
const call = (name: string, input: unknown): Promise<ToolResult> =>
workspace.handleToolCall({ id: name, name, input } as Parameters<WorkspaceModule['handleToolCall']>[0]);
const brief = (r: ToolResult) => r.success ? `ok ${JSON.stringify(r.data).slice(0, 300)}` : `refused: ${r.error}`;
try {
for (const p of ['work/real/notes.txt', 'work/link/notes.txt']) {
const r = await call('read', { path: p, offsetChars: 0, limitChars: 10_000 });
log(`\nread ${p}: ${r.success ? JSON.stringify((r.data as { content: string }).content) : 'refused: ' + r.error}`);
}
const many = await call('edit_many', { edits: [
{ path: 'work/real/notes.txt', oldString: 'alpha', newString: 'ALPHA' },
{ path: 'work/link/notes.txt', oldString: 'beta', newString: 'BETA' },
] });
log(`\nedit_many [real/notes.txt: alpha -> ALPHA, link/notes.txt: beta -> BETA]:\n ${brief(many)}`);
log(`\nstore tree after edit_many:`);
for (const p of ['real/notes.txt', 'link/notes.txt']) {
const r = await call('read', { path: `work/${p}`, offsetChars: 0, limitChars: 10_000 });
log(` read work/${p}: ${r.success ? JSON.stringify((r.data as { content: string }).content) : 'refused: ' + r.error}`);
}
log(`\ndisk real/notes.txt after edit_many: ${JSON.stringify(readFileSync(join(dir, 'real', 'notes.txt'), 'utf8'))}`);
const status = await call('status', {});
log(`\nstatus: ${JSON.stringify(status.data ?? status.error).slice(0, 1200)}`);
} finally {
store.close();
rmSync(root, { recursive: true, force: true });
}node v24.22.0, linux
mount <tmp>/mount: real/notes.txt = "alpha\nbeta\n"; link -> real (symlink)
read work/real/notes.txt: "alpha\nbeta\n"
read work/link/notes.txt: "alpha\nbeta\n"
edit_many [real/notes.txt: alpha -> ALPHA, link/notes.txt: beta -> BETA]:
refused: Every edit of this call is stored in the workspace, so don't send the call again: a second call can apply its edits twice.
Disk took 1 of the call's 2 files. This file's push didn't complete:
work/link/notes.txt: not written: disk changed since it was checked
store tree after edit_many:
read work/real/notes.txt: "ALPHA\nbeta\n"
read work/link/notes.txt: "alpha\nBETA\n"
disk real/notes.txt after edit_many: "ALPHA\nbeta\n"
status: {"work":{"path":"<tmp>/mount","mode":"read-write","watch":"never","fileCount":2,"lastMaterializedSeq":0,"currentSeq":13,"pendingChanges":1,"conflicts":0,"pendingWorkspaceDeletions":0,"initialSyncDone":false,"currentBranch":"main","lastMaterializedBranch":null,"canMaterialize":true}}
Our internal review of #463, which added edit_many, found it, and Cuthbert-2453, that change's author, judged it main's: by our reading, for every file tool a path through an in-mount link was already a second key for one file.
A shape to weigh: on a mount that follows symlinks, the workspace keys a path by where its in-mount links lead, so every spelling of a file names one entry, and edit_many plans each file once with all the edits that name it.
Written by Ruth-2424.
Activity