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

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/tool-fs/README.md
README.md: c9375a3ee803db48c547c7553d10f9b5ebde3fd2
README.zh.md: 70ff15f2beff2674719f3084594e5cc0c7309f68
README.md: 6878584b72c3e799ca8a46ddc6fc2908f9bc3acf
README.zh.md: d8e92d96c60d30ec4aa943b77b882517324c4e52

View File

@@ -108,7 +108,7 @@ Prefix-stable while the visible tool definitions and order are unchanged. Regist
#### What the model sees
A successful read is exactly `<path><displayPath></path>`, newline, `<type>file</type>`, newline, `<content>`, numbered lines as `<lineNumber>: <text>`, a blank line, one footer, and `</content>`. The footer is exactly `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`, `(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)`, or `(End of file - total <total> lines)`. A long line ends exactly `... (line truncated to <max> chars)`.
A successful read is exactly `<path><displayPath></path>`, newline, `<type>file</type>`, newline, `<content>`, numbered lines as `<lineNumber>: <text>`, a blank line, one footer, and `</content>`. The footer is exactly `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`, `(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)`, or `(End of file - total <total> lines)`. A long line ends exactly `... (line truncated to <max> chars)`. A missing read still returns `FS_NOT_FOUND`, but it records confirmed absence for the calling session; after an externally deleted file is re-read, a retried `write` can safely recreate it through the provider's no-replace guard.
#### Token effect
@@ -136,7 +136,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` (including a missing edit target) gets `— re-read the file, then retry`, `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved.
Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation.
#### Token effect

View File

@@ -108,7 +108,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
#### 模型看到的内容
成功读取结果精确为 `<path><displayPath></path>`、换行、`<type>file</type>`、换行、`<content>`、形如 `<lineNumber>: <text>` 的编号行、一个空行、一条 footer 和 `</content>`。footer 精确为 `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`、`(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)` 或 `(End of file - total <total> lines)`。长行结尾精确为 `... (line truncated to <max> chars)`。
成功读取结果精确为 `<path><displayPath></path>`、换行、`<type>file</type>`、换行、`<content>`、形如 `<lineNumber>: <text>` 的编号行、一个空行、一条 footer 和 `</content>`。footer 精确为 `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`、`(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)` 或 `(End of file - total <total> lines)`。长行结尾精确为 `... (line truncated to <max> chars)`。读取缺失目标仍返回 `FS_NOT_FOUND`,但会为调用会话记录确认缺失;外部删除的文件被重新读取后,重试的 `write` 可以通过提供方的不替换防护安全地重新创建该文件。
#### Token 影响
@@ -136,7 +136,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
#### 模型看到的内容
失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION`(包括编辑目标缺失)追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。
失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,edit 会报告 `FS_NOT_FOUND`,而不会重复陈旧恢复指令;write 则使用带防护的创建。
#### Token 影响

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,

View File

@@ -234,37 +234,33 @@ describe('default deployment (with dsh-fs-policy)', () => {
})
})
describe('deleted observed target (fail-closed corner)', () => {
it('a deleted observed file stays un-writable and un-editable in-session: the remedy cannot unblock it', async () => {
describe('deleted observed target', () => {
it('a failed reread records absence so write can safely recreate the file', async () => {
await writeFile(join(dir, 'a.txt'), 'original')
await call('read', { file_path: 'a.txt' })
await rm(join(dir, 'a.txt')) // out-of-band deletion
// Edit of the missing target: stale (the missing-target path shares the
// stale code and the re-read remedy).
// The original positive observation still protects the first mutation.
const edit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' })
expect(edit.isError).toBe(true)
expect(edit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// Re-reading the missing file FAILS with FS_NOT_FOUND and records no
// observation, so the retried edit fails identically: the observed entry
// is never cleared for a deleted target.
const reread = await call('read', { file_path: 'a.txt' })
expect(reread.isError).toBe(true)
expect(reread.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
const retriedEdit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' })
expect(retriedEdit.isError).toBe(true)
expect(retriedEdit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// Write cannot recreate it either: the stale observation still forces
// replaceIfVersion, which rejects a missing target ("file no longer exists").
const write = await call('write', { file_path: 'a.txt', content: 'fresh' })
const write = await call('write', { file_path: 'a.txt', content: 'premature' })
expect(write.isError).toBe(true)
expect(write.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// The dead end lifts once the file exists again and is freshly observed.
await writeFile(join(dir, 'a.txt'), 'restored')
expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false)
// A read-not-found is an authoritative negative observation for this
// owner. It still fails as a read, but changes the next write guard.
const reread = await call('read', { file_path: 'a.txt' })
expect(reread.isError).toBe(true)
expect(reread.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
// Absence never authorizes edit: there is no content/version to edit.
const retriedEdit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' })
expect(retriedEdit.isError).toBe(true)
expect(retriedEdit.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
// The retried write uses createIfAbsent; the provider remains responsible
// for rejecting a concurrent creator at publication time.
const recovered = await call('write', { file_path: 'a.txt', content: 'fresh' })
expect(recovered.isError).toBe(false)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('fresh')
@@ -294,6 +290,20 @@ describe('default deployment (with dsh-fs-policy)', () => {
expect(statSpy).not.toHaveBeenCalled()
statSpy.mockRestore()
})
it('a missing read still stats once and its recovery write stats zero times', async () => {
const statSpy = vi.spyOn(ctx.fs, 'stat')
const missing = await call('read', { file_path: 'missing.txt' })
expect(missing.isError).toBe(true)
expect(missing.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
expect(statSpy).toHaveBeenCalledTimes(1)
statSpy.mockClear()
const created = await call('write', { file_path: 'missing.txt', content: 'fresh' })
expect(created.isError).toBe(false)
expect(statSpy).not.toHaveBeenCalled()
statSpy.mockRestore()
})
})
})
@@ -482,7 +492,7 @@ describe('signal, concurrency, and the fs/observed contract', () => {
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
// Reproduce an older concurrent read winning the observation race.
ctx.emit('fs/observed', target, firstInfo.version, { agent: { session } })
ctx.emit('fs/observed', target, { kind: 'present', version: firstInfo.version }, { agent: { session } })
const edit = await callOwned('edit', {
file_path: 'a.txt',