fix(fs): observe absence before guarded recreation

This commit is contained in:
Tianyi Cui
2026-08-09 15:22:50 +08:00
parent 9aa2d07353
commit ceba53edd7
68 changed files with 694 additions and 249 deletions

View File

@@ -137,8 +137,8 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
// model-facing remedy; anything else passes through.
throw remediateFsError(sandbox.mapError(error, sandboxPolicy))
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
// Record the present observation (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
return {
path: target.displayPath,
before: outcome.before,

View File

@@ -138,10 +138,13 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
const input = parseReadArgs(args, caps.limit)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath))
// One stat: type check + size routing + the version recorded as observed.
// One stat: absence observation OR type check + size routing + present version.
// A concurrent write can only make a later guarded mutation fail stale and require reread.
const info = await ctx.fs.stat(target, exec.signal)
if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
if (!info) {
ctx.emit('fs/observed', target, { kind: 'absent' }, exec)
throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
}
if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
// Stream when the file is large OR size is unknown, so a size-less backend
@@ -161,10 +164,10 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
lines: window.lines,
totalLines: window.totalLines,
}
// Record the observed version (a no-op when no policy plugin listens). The
// Record the present observation (a no-op when no policy plugin listens). The
// read already succeeded; an fs/observed listener is contractually a
// synchronous, side-effect-only recorder.
ctx.emit('fs/observed', target, info.version, exec)
ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec)
return outcome
},
// Result-time display: a `read` card carrying the structured line window a

View File

@@ -118,8 +118,8 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
// model-facing remedy; anything else passes through.
throw remediateFsError(sandbox.mapError(error, sandboxPolicy))
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
// Record the present observation (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
return {
path: target.displayPath,
operation: outcome.operation,