fix(fs): observe absence before guarded recreation
This commit is contained in:
@@ -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 .agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md
|
||||
2026-06-26-file-context-as-event-gate.md: df1a43e252cff02b287497210664c3d14dcd35a9
|
||||
2026-06-26-file-context-as-event-gate.zh.md: 3d784c9e150430c8054f6b08ccc5c66fa85e09b4
|
||||
2026-06-26-file-context-as-event-gate.md: 353beb2498a310eee8cc794fa5edc22915fd6ad0
|
||||
2026-06-26-file-context-as-event-gate.zh.md: 37d7c1e4d971896a0e6ddeb42650635b8f755ca0
|
||||
|
||||
@@ -33,14 +33,16 @@ provider dsh-fs-local local implementation of ctx.fs
|
||||
|
||||
The model is additive: bare `ctx.fs` performs atomic, unconstrained text I/O, while `dsh-fs-policy` adds observed state, read-before-edit, and version guards. Removing the policy therefore leaves the tools usable but unconstrained. Shipped agent configs load the policy; the bare mode exists to keep policy optional at the service boundary, not as the normal deployment stance.
|
||||
|
||||
The [filesystem absence-observation follow-up](../bug-fix/2026-08-09-filesystem-absence-observation.md) refines the recording payload from a success-only version to explicit present/absent state and requires guarded creation to publish without replacement. The event-gate ownership and no-I/O policy boundary remain unchanged.
|
||||
|
||||
`dsh-tool-fs` no longer injects `fileContext`. It injects `fs` and `tools`/`systemPrompt`.
|
||||
|
||||
## The policy is enforced by provider CAS, not by `dsh-fs-policy` stat
|
||||
|
||||
`dsh-fs-policy` enforces "you must write/edit based on the version you read" **without ever calling `stat` or comparing versions itself**. It supplies the observed version as the CAS basis and lets the provider's mutation critical section detect staleness:
|
||||
|
||||
- "Have you read this file?" is the one thing `dsh-fs-policy` decides locally — a `WeakMap` lookup, no I/O. No record ⇒ `FS_NOT_OBSERVED`.
|
||||
- "Is the version you read still current?" is decided **inside `ctx.fs.editText`/`writeText`**, in the same atomic lock that performs the read-match-rename. `dsh-fs-policy` passes `vObserved` as the expectation; the provider raises `FS_STALE_VERSION` if the file has moved on.
|
||||
- "What did this owner last observe?" is the one thing `dsh-fs-policy` decides locally — a `WeakMap` lookup, no I/O. No record means unseen; an absent record permits only guarded creation; a present record carries the replacement/edit basis.
|
||||
- "Is the version still current, or is the create target still absent?" is decided **inside the provider's atomic mutation boundary**. `dsh-fs-policy` supplies `replaceIfVersion` or `createIfAbsent`; the provider raises `FS_STALE_VERSION` for a moved version and `FS_NOT_OBSERVED` when a guarded create loses to another creator.
|
||||
|
||||
This is deliberate. If `dsh-fs-policy` stat-ed and compared versions in its waterfall handler, there would be a TOCTOU gap between that check and the tool's actual write — the file could change in between, so the check would be a false guarantee that the provider's lock has to back up anyway. Putting the version check in the provider's critical section is both race-free and zero extra `stat`. So `dsh-fs-policy` does **no** filesystem I/O; the "must be based on the latest read" guarantee is *realized* by CAS, and `dsh-fs-policy` only chooses the basis (`vObserved`) and gates on prior observation.
|
||||
|
||||
@@ -68,14 +70,14 @@ The `FsWriteIntent` union itself does not change — the third "unconditional" s
|
||||
|
||||
The events live in `@deepseek-ai/dsh-fs`, not in `dsh-fs-policy`. This is forced by the decoupling contract: `dsh-tool-fs` is the emitter, so it must reference the event types, and it must keep compiling even though `dsh-fs-policy` no longer provides a method service. `dsh-fs` is the package both `dsh-tool-fs` and `dsh-fs-policy` already depend on, so it is the only home that lets the emitter and the policy listener share a vocabulary without the emitter depending on the policy plugin.
|
||||
|
||||
These events carry existing `dsh-fs` vocabulary (`FsTarget`, `FsVersion`, `FsWriteIntent`) plus an opaque actor — not model-facing concepts (no line windows, numbered lines, or rendered footers leak down).
|
||||
These events carry existing `dsh-fs` vocabulary (`FsTarget`, `FsVersion`, `FsObservation`, `FsWriteIntent`) plus an opaque actor — not model-facing concepts (no line windows, numbered lines, or rendered footers leak down).
|
||||
|
||||
**The two `fs/*` decision events are single-slot, first-wins waterfalls.** `dsh-fs-policy` returns without calling `next()`, so it owns the slot in the default deployment; a listener registered earlier or with `prepend` would replace that policy. Permission, audit, and sandbox concerns remain on the composable `tools/execute` waterfall.
|
||||
|
||||
The actor is typed `object` in `dsh-fs` — a pure opaque carrier the provider contract never reads or narrows. The owner-derivation (`actor.agent?.session`) and the `{ agent?: { session? } }` structural shape stay entirely inside `dsh-fs-policy`, which narrows the `object` actor to that shape in its listeners. `dsh-fs` owns the event names and the fs vocabulary; it does NOT own the policy layer's runtime owner structure.
|
||||
|
||||
```ts
|
||||
import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsObservation, FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
@@ -95,14 +97,14 @@ interface Events {
|
||||
*/
|
||||
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
|
||||
/**
|
||||
* Record that an actor observed a target at a version, after a successful
|
||||
* read/write/edit. Fire-and-forget (plain emit). Listeners MUST be
|
||||
* Record that an actor observed a target as present at a version or absent.
|
||||
* Fire-and-forget (plain emit). Listeners MUST be
|
||||
* synchronous, side-effect-only recorders (`dsh-fs-policy`'s is a WeakMap
|
||||
* write); the tool does not guard the emit, so a throwing listener surfaces as
|
||||
* the tool's isError result. No listener ⇒ nothing recorded.
|
||||
* @mode emit
|
||||
*/
|
||||
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
|
||||
'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void
|
||||
}
|
||||
```
|
||||
|
||||
@@ -118,23 +120,23 @@ The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte un
|
||||
|
||||
`stat` budget is minimized by letting the waterfall produce the expectation lazily — the bare default returns `undefined` (no guard) and never stats:
|
||||
|
||||
- **read** — one `stat` (type + size routing + version), then `readText`/`streamText`, then `buildWindow`, then an `emit('fs/observed', target, info.version, exec)`. The post-read confirming `stat` from the old `fileContext.read` is dropped; a writer racing between the routing stat and the read can at worst make a *later* guarded edit spuriously `FS_STALE_VERSION` (fail-closed: the model re-reads, never writes against the wrong version, since `editText` re-checks in its lock).
|
||||
- **write** — `expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`, then `ctx.fs.writeText(target, content, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** with or without `dsh-fs-policy`.
|
||||
- **edit** — `expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`, then `ctx.fs.editText(target, edit, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** in both cases: the bare default is `undefined` (unconditional edit), so the tool never stats to manufacture a basis. If the target is absent, the provider reports `FS_STALE_VERSION` even on the unguarded path.
|
||||
- **read** — one `stat`; a metadata miss emits `{ kind: 'absent' }` before returning `FS_NOT_FOUND`, while a file routes through `readText`/`streamText`, `buildWindow`, then emits `{ kind: 'present', version: info.version }`. The post-read confirming `stat` from the old `fileContext.read` stays dropped; a writer racing between the routing stat and the read can at worst make a later guarded edit spuriously stale.
|
||||
- **write** — `expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`, then `ctx.fs.writeText(target, content, expectation)`, then emit the present outcome version. **Zero stat in the tool** with or without `dsh-fs-policy`.
|
||||
- **edit** — `expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`, then `ctx.fs.editText(target, edit, expectation)`, then emit the present outcome version. **Zero stat in the tool** in both cases: the bare default is `undefined` (unconditional edit), so the tool never stats to manufacture a basis. If the target is absent on the bare path, the provider reports `FS_STALE_VERSION`; the policy returns `FS_NOT_FOUND` directly when it already holds an absent observation.
|
||||
|
||||
The tool passes `exec` (the tool-execution context) as the `actor` argument on every dispatch, so `dsh-fs-policy` can derive its observed-state owner. The tool does not know whether the policy plugin is present: it always provides the bare default behavior in the `next` thunk, and `dsh-fs-policy` short-circuits the thunk before it runs in the default deployment.
|
||||
|
||||
**`fs/observed` fires after a successful operation.** Its listeners must be synchronous, non-throwing recorders; the tool does not guard the plain emit, so a throwing listener would report failure after a mutation already succeeded. Async or fallible observation needs a separate event contract.
|
||||
**`fs/observed` fires after a successful operation and after a metadata probe confirms absence.** Its listeners must be synchronous, non-throwing recorders; the tool does not guard the plain emit, so a throwing listener can replace the pending read error or report failure after a mutation already succeeded. Async or fallible observation needs a separate event contract.
|
||||
|
||||
## Policy plugin contract (`dsh-fs-policy`)
|
||||
|
||||
`dsh-fs-policy` is a plugin, not a service. It does not register `ctx.fileContext`, has no public method surface, and exposes no `read`/`write`/`edit`/`resolve` methods. It attaches three listeners via `ctx.on()` registrations (each returning a disposer for HMR). It keeps the observed-state `WeakMap<owner, Map<targetKey, { version }>>` and the structural owner derivation (narrowing the event's opaque `object` actor to its own `{ agent?: { session? } }` shape), but does not inject `fs` — every handler operates only on its own `WeakMap`, never on `ctx.fs`.
|
||||
`dsh-fs-policy` is a plugin, not a service. It does not register `ctx.fileContext`, has no public method surface, and exposes no `read`/`write`/`edit`/`resolve` methods. It attaches three listeners via `ctx.on()` registrations (each returning a disposer for HMR). It keeps the observed-state `WeakMap<owner, Map<targetKey, FsObservation>>` and the structural owner derivation (narrowing the event's opaque `object` actor to its own `{ agent?: { session? } }` shape), but does not inject `fs` — every handler operates only on its own `WeakMap`, never on `ctx.fs`.
|
||||
|
||||
- `fs/write-intent` listener: `prior = getObserved(owner, key)`; return `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`. It does NOT call `next()`: it fully owns the single decision slot.
|
||||
- `fs/edit-intent` listener: `prior = getObserved(owner, key)`; if no `owner` or no `prior`, throw `FS_NOT_OBSERVED`; else return `{ version: prior.version }`. Also does not call `next()`.
|
||||
- `fs/observed` listener: `record(owner, key, version)`.
|
||||
- `fs/write-intent` listener: unseen/absent ⇒ `createIfAbsent`; present ⇒ `replaceIfVersion`. It does NOT call `next()`: it fully owns the single decision slot.
|
||||
- `fs/edit-intent` listener: unseen ⇒ `FS_NOT_OBSERVED`; absent ⇒ `FS_NOT_FOUND`; present ⇒ its version guard. It does NOT call `next()`.
|
||||
- `fs/observed` listener: record the present/absent discriminated value.
|
||||
|
||||
An observed-state entry is the **prior-observation record**: a successful `read`, `write`, OR `edit` all emit `fs/observed` and record `{ version }`, so the entry's presence means "this owner has observed this target at this version", not narrowly "has read it". This is what lets a create-then-edit or edit-then-edit sequence work without an intervening re-read: the mutation refreshes the recorded version to its own result, so the next edit's basis is the version it just produced. `FS_NOT_OBSERVED` rejects only an edit with NO prior observation of any kind. The owner is derived structurally from `{ agent?: { session? } }`; disposal drops all state (HMR safety).
|
||||
An observed-state entry is the **prior-observation record**, but its discriminant matters. Successful read/write/edit records present at a version, allowing create-then-edit or edit-then-edit without an intervening read. A read/view that confirms absence replaces any old positive version with absent, allowing only a guarded create; a later successful create replaces it with the new present version. Missing entry alone means unseen and produces `FS_NOT_OBSERVED` for edit. The owner is derived structurally from `{ agent?: { session? } }`; disposal drops all state (HMR safety).
|
||||
|
||||
`dsh-fs-policy` is now a pure policy/recording plugin with no service surface — it influences the world only through the event gate. That is what removes the method coupling from `dsh-tool-fs`.
|
||||
|
||||
@@ -154,7 +156,7 @@ This amends — does not reverse — [the split-fs-seam Agent Note](../simplific
|
||||
|
||||
## Verification
|
||||
|
||||
Tests pin both paths: without `dsh-fs-policy`, the root tool plugin boots against `dsh-fs-local`, and read, create, overwrite, and unread edit succeed; with the policy, unread edit returns `FS_NOT_OBSERVED` and unread overwrite is gated by `createIfAbsent`. A later intent listener is not reached after the policy decides. Stale edits fail through provider CAS while the policy performs no `stat`; the tool budgets remain one `stat` for read and zero for write or edit on either path. Model-facing schemas remain byte-for-byte unchanged, so snapshots do not change.
|
||||
Tests pin both paths: without `dsh-fs-policy`, the root tool plugin boots against `dsh-fs-local`, and read, create, overwrite, and unread edit succeed; with the policy, unread edit returns `FS_NOT_OBSERVED` and unread overwrite is gated by `createIfAbsent`. A later intent listener is not reached after the policy decides. Stale edits fail through provider CAS while the policy performs no `stat`; the tool budgets remain one `stat` for read and zero for write or edit on either path. The deletion recovery path is also assembled: stale mutation, missing reread, guarded recreation. Model-facing schemas remain byte-for-byte unchanged, while the recovered result transcript changes.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -33,14 +33,16 @@ provider dsh-fs-local local implementation of ctx.fs
|
||||
|
||||
该模型是叠加式的:裸 `ctx.fs` 执行原子化、无约束的文本 I/O,而 `dsh-fs-policy` 叠加观测状态、先读后编辑和版本守卫。因此移除策略层后工具仍可用,只是不受约束。正式发布的 agent 配置会加载策略;裸模式的存在是为了让策略在服务边界保持可选,而非作为正常部署姿态。
|
||||
|
||||
[文件系统缺失观测后续决策](../bug-fix/2026-08-09-filesystem-absence-observation.md)把记录载荷从仅表示成功的版本细化为显式的存在/缺失状态,并要求带防护的创建以不替换方式发布。事件门禁归属与无 I/O 策略边界保持不变。
|
||||
|
||||
`dsh-tool-fs` 不再注入 `fileContext`。它注入 `fs` 和 `tools`/`systemPrompt`。
|
||||
|
||||
## 策略由提供方 CAS 强制执行,而非 `dsh-fs-policy` 的 stat
|
||||
|
||||
`dsh-fs-policy` 强制执行「你必须基于你读到的版本来写入/编辑」,**自身从不调用 `stat` 或比较版本**。它将观测到的版本作为 CAS 基准提供,让提供方的 mutation 临界区检测陈旧性:
|
||||
|
||||
- 「你读过这个文件吗?」是 `dsh-fs-policy` 在本地决定的唯一事项——一次 `WeakMap` 查找,无 I/O。无记录 ⇒ `FS_NOT_OBSERVED`。
|
||||
- 「你读到的版本是否仍为最新?」由 **`ctx.fs.editText`/`writeText` 内部**决定,在执行 read-match-rename 的同一个原子锁中完成。`dsh-fs-policy` 将 `vObserved` 作为期望值传入;如果文件已变更,提供方抛出 `FS_STALE_VERSION`。
|
||||
- 「该所有者最近观测到了什么?」是 `dsh-fs-policy` 在本地决定的唯一事项——一次 `WeakMap` 查找,无 I/O。无记录表示未见;缺失记录只允许带防护的创建;存在记录携带替换/编辑基准。
|
||||
- 「版本是否仍然有效,或者创建目标是否仍然缺失?」由**提供方的原子变更边界内部**决定。`dsh-fs-policy` 提供 `replaceIfVersion` 或 `createIfAbsent`;对于已经变化的版本,提供方抛出 `FS_STALE_VERSION`;带防护的创建若败给另一个创建者,则抛出 `FS_NOT_OBSERVED`。
|
||||
|
||||
这是有意为之的。如果 `dsh-fs-policy` 在其 waterfall(瀑布式事件)处理器中 stat 并比较版本,该检查与工具实际写入之间会存在 TOCTOU 间隙——文件可能在此期间变化,因此该检查只是一个虚假保证,提供方的锁无论如何都要兜底。将版本检查放在提供方的临界区中既无竞态又无额外 `stat`。所以 `dsh-fs-policy` **不做**任何文件系统 I/O;「必须基于最近一次读取」的保证由 CAS *实现*,`dsh-fs-policy` 只负责选择基准(`vObserved`)并对先前观测进行门控。
|
||||
|
||||
@@ -68,14 +70,14 @@ editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion
|
||||
|
||||
事件定义在 `@deepseek-ai/dsh-fs` 中,而非 `dsh-fs-policy` 中。这是解耦约定所迫:`dsh-tool-fs` 是发射方,因此它必须引用事件类型,且即使 `dsh-fs-policy` 不再提供方法服务,它也必须能编译通过。`dsh-fs` 是 `dsh-tool-fs` 和 `dsh-fs-policy` 都已依赖的包,因此它是唯一能让发射方和策略监听方共享词汇而不让发射方依赖策略插件的归属地。
|
||||
|
||||
这些事件携带既有的 `dsh-fs` 词汇(`FsTarget`、`FsVersion`、`FsWriteIntent`)加一个不透明的 actor——不携带面向模型的概念(行窗口、行号或渲染后的页脚不会泄漏到此层)。
|
||||
这些事件携带既有的 `dsh-fs` 词汇(`FsTarget`、`FsVersion`、`FsObservation`、`FsWriteIntent`)加一个不透明的 actor——不携带面向模型的概念(行窗口、行号或渲染后的页脚不会泄漏到此层)。
|
||||
|
||||
**两个 `fs/*` 决策事件是单槽、先到先得的 waterfall。** `dsh-fs-policy` 不调用 `next()` 直接返回,因此在默认部署中它占据该槽位;更早注册或使用 `prepend` 的监听器会替代该策略。权限、审计和沙箱关注点仍留在可组合的 `tools/execute` waterfall 上。
|
||||
|
||||
actor 在 `dsh-fs` 中类型为 `object`——一个纯粹的不透明载体,提供方约定从不读取或收窄它。owner 的推导(`actor.agent?.session`)和 `{ agent?: { session? } }` 结构形状完全留在 `dsh-fs-policy` 内部,由其在监听器中将 `object` actor 收窄为该形状。`dsh-fs` 拥有事件名和 fs 词汇;它不拥有策略层的运行时 owner 结构。
|
||||
|
||||
```ts
|
||||
import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsObservation, FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
@@ -95,14 +97,14 @@ interface Events {
|
||||
*/
|
||||
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
|
||||
/**
|
||||
* Record that an actor observed a target at a version, after a successful
|
||||
* read/write/edit. Fire-and-forget (plain emit). Listeners MUST be
|
||||
* Record that an actor observed a target as present at a version or absent.
|
||||
* Fire-and-forget (plain emit). Listeners MUST be
|
||||
* synchronous, side-effect-only recorders (`dsh-fs-policy`'s is a WeakMap
|
||||
* write); the tool does not guard the emit, so a throwing listener surfaces as
|
||||
* the tool's isError result. No listener ⇒ nothing recorded.
|
||||
* @mode emit
|
||||
*/
|
||||
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
|
||||
'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void
|
||||
}
|
||||
```
|
||||
|
||||
@@ -118,23 +120,23 @@ interface Events {
|
||||
|
||||
通过让 waterfall 惰性产出期望值来最小化 `stat` 预算——裸默认返回 `undefined`(无守卫),从不 stat:
|
||||
|
||||
- **read**——一次 `stat`(类型 + 大小路由 + 版本),然后 `readText`/`streamText`,然后 `buildWindow`,然后 `emit('fs/observed', target, info.version, exec)`。旧 `fileContext.read` 中读后确认的 `stat` 被移除;在路由 stat 和读取之间竞争的写入者最多只能使*后续*有守卫的编辑误报 `FS_STALE_VERSION`(为安全起见拒绝写入:模型会重新读取;由于 `editText` 会在其锁内复查,模型绝不会基于错误版本写入)。
|
||||
- **write**——`expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`,然后 `ctx.fs.writeText(target, content, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。无论是否有 `dsh-fs-policy`,**工具内零 stat**。
|
||||
- **edit**——`expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`,然后 `ctx.fs.editText(target, edit, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。两种情况下**工具内零 stat**:裸默认为 `undefined`(无条件编辑),因此工具从不 stat 来制造基准。如果目标不存在,提供方即使在无守卫路径上也报告 `FS_STALE_VERSION`。
|
||||
- **read**——一次 `stat`;元数据未命中时,在返回 `FS_NOT_FOUND` 前 emit `{ kind: 'absent' }`;目标为文件时,则依次执行 `readText`/`streamText`、`buildWindow`,再 emit `{ kind: 'present', version: info.version }`。旧 `fileContext.read` 中读后确认的 `stat` 仍保持移除;在路由 stat 和读取之间竞争的写入者最多只能使后续带防护的编辑误报陈旧。
|
||||
- **write**——`expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`,然后 `ctx.fs.writeText(target, content, expectation)`,再 emit 表示存在的结果版本。无论是否有 `dsh-fs-policy`,**工具内零 stat**。
|
||||
- **edit**——`expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`,然后 `ctx.fs.editText(target, edit, expectation)`,再 emit 表示存在的结果版本。两种情况下**工具内零 stat**:裸默认为 `undefined`(无条件编辑),因此工具从不 stat 来制造基准。如果裸路径上的目标不存在,提供方报告 `FS_STALE_VERSION`;策略已持有缺失观测时,则直接返回 `FS_NOT_FOUND`。
|
||||
|
||||
工具在每次分发时将 `exec`(工具执行上下文)作为 `actor` 参数传入,以便 `dsh-fs-policy` 推导其观测状态的 owner。工具不知道策略插件是否存在:它始终在 `next` thunk 中提供裸默认行为,而 `dsh-fs-policy` 在默认部署中会在 thunk 运行前短路它。
|
||||
|
||||
**`fs/observed` 在操作成功后触发。** 其监听器必须是同步、不抛异常的记录器;工具不对 plain emit 做保护,因此抛异常的监听器会在 mutation 已成功后报告失败。异步或可失败的观测需要另一份事件约定。
|
||||
**`fs/observed` 在操作成功后,以及元数据探测确认缺失后触发。** 其监听器必须是同步、不抛异常的记录器;工具不对 plain emit 做保护,因此抛异常的监听器可能取代待返回的读取错误,或在 mutation 已成功后报告失败。异步或可失败的观测需要另一份事件约定。
|
||||
|
||||
## 策略插件约定(`dsh-fs-policy`)
|
||||
|
||||
`dsh-fs-policy` 是插件,不是服务。它不注册 `ctx.fileContext`,没有公开方法面,不暴露 `read`/`write`/`edit`/`resolve` 方法。它通过 `ctx.on()` 注册三个监听器(每个返回一个 disposer 用于 HMR)。它维护观测状态 `WeakMap<owner, Map<targetKey, { version }>>`,以及结构化的 owner 推导(将事件中不透明的 `object` actor 收窄为自己的 `{ agent?: { session? } }` 形状),但不注入 `fs`——每个处理器只操作自己的 `WeakMap`,从不操作 `ctx.fs`。
|
||||
`dsh-fs-policy` 是插件,不是服务。它不注册 `ctx.fileContext`,没有公开方法面,不暴露 `read`/`write`/`edit`/`resolve` 方法。它通过 `ctx.on()` 注册三个监听器(每个返回一个 disposer 用于 HMR)。它维护观测状态 `WeakMap<owner, Map<targetKey, FsObservation>>`,以及结构化的 owner 推导(将事件中不透明的 `object` actor 收窄为自己的 `{ agent?: { session? } }` 形状),但不注入 `fs`——每个处理器只操作自己的 `WeakMap`,从不操作 `ctx.fs`。
|
||||
|
||||
- `fs/write-intent` 监听器:`prior = getObserved(owner, key)`;返回 `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`。它不调用 `next()`:完全占据单一决策槽位。
|
||||
- `fs/edit-intent` 监听器:`prior = getObserved(owner, key)`;如果无 `owner` 或无 `prior`,抛出 `FS_NOT_OBSERVED`;否则返回 `{ version: prior.version }`。同样不调用 `next()`。
|
||||
- `fs/observed` 监听器:`record(owner, key, version)`。
|
||||
- `fs/write-intent` 监听器:未见/缺失 ⇒ `createIfAbsent`;存在 ⇒ `replaceIfVersion`。它不调用 `next()`:完全占据单一决策槽位。
|
||||
- `fs/edit-intent` 监听器:未见 ⇒ `FS_NOT_OBSERVED`;缺失 ⇒ `FS_NOT_FOUND`;存在 ⇒ 返回其版本守卫。同样不调用 `next()`。
|
||||
- `fs/observed` 监听器:记录存在/缺失的可辨识值。
|
||||
|
||||
一条观测状态条目是**先前观测记录**:成功的 `read`、`write` 或 `edit` 都会 emit `fs/observed` 并记录 `{ version }`,因此条目的存在意味着「此 owner 在此版本观测过此目标」,而非狭义的「已读取过」。这使得 create-then-edit 或 edit-then-edit 序列无需中间重新读取即可工作:mutation 将记录的版本刷新为自身的结果,因此下一次编辑的基准就是它刚产出的版本。`FS_NOT_OBSERVED` 只拒绝完全没有任何先前观测的编辑。owner 从 `{ agent?: { session? } }` 结构化推导;dispose 时丢弃所有状态(HMR 安全)。
|
||||
一条观测状态条目是**先前观测记录**,但其可辨识字段会影响决策。成功的 read/write/edit 会记录存在状态及版本,使 create-then-edit 或 edit-then-edit 序列无需中间重新读取即可工作。确认缺失的 read/view 会用缺失状态取代旧的正向版本,因此只允许带防护的创建;随后成功的创建会再用新的存在版本取代缺失状态。只有条目不存在才表示未见,并使 edit 返回 `FS_NOT_OBSERVED`。owner 从 `{ agent?: { session? } }` 结构化推导;dispose 时丢弃所有状态(HMR 安全)。
|
||||
|
||||
`dsh-fs-policy` 现在是一个纯策略/记录插件,没有服务面——它只通过事件门控影响外界。这正是移除 `dsh-tool-fs` 方法耦合的关键。
|
||||
|
||||
@@ -154,7 +156,7 @@ interface Events {
|
||||
|
||||
## 验证
|
||||
|
||||
测试固定了两条路径:无 `dsh-fs-policy` 时,根工具插件对 `dsh-fs-local` 启动,read、create、overwrite 和未读 edit 均成功;有策略时,未读 edit 返回 `FS_NOT_OBSERVED`,未读 overwrite 被 `createIfAbsent` 门控。策略决定后,后注册的 intent 监听器不会被触达。陈旧编辑通过提供方 CAS 失败,而策略不执行 `stat`;工具预算在两条路径上保持 read 一次 `stat`,write 或 edit 均为零次。面向模型的 schema 逐字节不变,因此快照不变。
|
||||
测试固定了两条路径:无 `dsh-fs-policy` 时,根工具插件对 `dsh-fs-local` 启动,read、create、overwrite 和未读 edit 均成功;有策略时,未读 edit 返回 `FS_NOT_OBSERVED`,未读 overwrite 被 `createIfAbsent` 门控。策略决定后,后注册的 intent 监听器不会被触达。陈旧编辑通过提供方 CAS 失败,而策略不执行 `stat`;工具预算在两条路径上保持 read 一次 `stat`,write 或 edit 均为零次。测试也组装了删除恢复路径:陈旧变更、重新读取时确认缺失、带防护的重新创建。面向模型的 schema 逐字节不变,但恢复后的结果 transcript(文本记录)发生变化。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 .agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.md
|
||||
2026-08-09-filesystem-absence-observation.md: e0c5c8b1845550ac6380ea4b2782cde13cba3ce9
|
||||
2026-08-09-filesystem-absence-observation.zh.md: 808698a7cb7a3575ef500dced7a646691d65c424
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: Filesystem absence is an observation and guarded creation never replaces
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-09-filesystem-absence-observation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The event-gated filesystem policy originally records only successful reads and mutations as a target version. If a session reads a file and an external command deletes it, the first guarded mutation correctly fails stale, but the prescribed reread returns `FS_NOT_FOUND` before emitting `fs/observed`. The old positive version therefore remains forever: write keeps choosing `replaceIfVersion`, the provider keeps rejecting the missing target, and the model-facing “re-read the file, then retry” instruction becomes an unrecoverable loop.
|
||||
|
||||
Treating a failed read as permission to create also exposes a second boundary. Both local and E2B providers probe before staging, then historically publish with rename; another process can create the target between those steps and be overwritten even though the caller supplied `createIfAbsent`. An in-process target lock does not protect that cross-process publication race.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-fs` owns an explicit observation union: `{ kind: 'present', version: FsVersion } | { kind: 'absent' }`. The `fs/observed` event carries that union. Successful reads and mutations emit present; a `read` or `str_replace_editor view` metadata miss emits absent synchronously before returning `FS_NOT_FOUND`. Other read failures do not manufacture absence.
|
||||
|
||||
`dsh-fs-policy` stores three logical states per owner and target without injecting or calling `ctx.fs`: missing map entry is unseen, `absent` is confirmed absence, and `present(version)` is a replacement/edit basis. Write maps unseen and absent to the existing `createIfAbsent` intent and present to `replaceIfVersion`. Edit maps unseen to `FS_NOT_OBSERVED`, absent to `FS_NOT_FOUND`, and present to its version guard. A successful create or mutation replaces absence with its produced present version.
|
||||
|
||||
Every provider must enforce `createIfAbsent` at the publication point, not only at its initial probe. `dsh-fs-local` stages and fsyncs in a private sibling directory, then hard-links the staged file to the destination; an existing destination makes the no-replace link fail and preserves the competitor. `dsh-fs-e2b` uses remote `ln` with an explicit created/existing result and derives the committed target version from metadata obtained before the non-cancellable commit. Replacements and bare unconditional writes retain their existing publication paths.
|
||||
|
||||
This decision does not claim cross-process linearizability for `replaceIfVersion`: the provider version check and replacement remain protected only against writers represented by the provider's own lock and detectable metadata. The narrower guarantee is exact and sufficient for absence recovery: guarded creation never clobbers a target that appears before publication.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Delete the cached version when a read returns not found.** Rejected because it conflates unseen with confirmed absence, cannot give edit the correct `FS_NOT_FOUND` result, and erases the state transition the event is meant to communicate.
|
||||
- **Have `dsh-fs-policy` call `stat` before choosing an intent.** Rejected because it makes the event-only policy depend on a provider, adds I/O to every decision, and still leaves a TOCTOU gap before publication.
|
||||
- **Let `replaceIfVersion` create when its target disappeared.** Rejected because a positive observation is evidence for replacement, not creation; silently changing that provider intent would bypass the required missing reread and weaken stale protection.
|
||||
- **Keep the deleted-target dead end fail-closed.** Rejected because the model-facing recovery instruction is then false and a normal external cleanup cannot be recovered within the session.
|
||||
|
||||
## Consequences
|
||||
|
||||
The first mutation after an unobserved external deletion still fails `FS_STALE_VERSION`; the user or model must follow the existing reread remedy. That missing reread returns `FS_NOT_FOUND` while changing policy state, after which edit remains forbidden and write may recreate the path. If another writer wins the create race, the retry returns `FS_NOT_OBSERVED` and leaves the winner intact.
|
||||
|
||||
The observation payload is a package-owned event contract change, so every producer, listener, invariant, generated Cordis catalog, subsystem document, and both filesystem tool families move together. The policy keeps its one-stat read and zero-stat write/edit budget, owner isolation, disposal behavior, and optional deployment boundary from the [event-gate decision](../architecture/2026-06-26-file-context-as-event-gate.md).
|
||||
|
||||
The assembled filesystem snapshot pins the model-visible recovery chain, while provider tests inject a creator after staging to prove no-clobber publication. The [guarded-mutation remedy decision](../feature/2026-08-03-fs-tool-error-remedy.md) remains the owner of model-facing recovery wording; this note makes its deletion path actionable.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: 文件系统中的缺失是一种观测,带防护的创建绝不执行替换
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-09-filesystem-absence-observation.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
事件门控的文件系统策略最初只把成功读取和变更记录成目标版本。如果某个会话读取文件后,外部命令将其删除,第一次带防护的变更会正确地因陈旧而失败,但按指示执行的重新读取会在发出 `fs/observed` 前返回 `FS_NOT_FOUND`。因此,旧的存在版本会一直保留:写入仍不断选择 `replaceIfVersion`,提供方仍不断拒绝缺失目标,而面向模型的「re-read the file, then retry」指令则形成无法恢复的循环。
|
||||
|
||||
把一次失败的读取视作创建授权,还会暴露第二个边界。本地与 E2B 提供方都会先探测再暂存,此前随后通过 rename 发布;另一进程可能在两步之间创建目标,即使调用方提供了 `createIfAbsent`,该目标仍会被覆盖。进程内目标锁无法防范这种跨进程发布竞态。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-fs` 拥有一个显式观测联合类型:`{ kind: 'present', version: FsVersion } | { kind: 'absent' }`。`fs/observed` 事件携带该联合类型。成功的读取与变更发出存在观测;`read` 或 `str_replace_editor view` 的元数据未命中会在返回 `FS_NOT_FOUND` 前同步发出缺失观测。其他读取失败不会产生缺失观测。
|
||||
|
||||
`dsh-fs-policy` 按所有者与目标存储三种逻辑状态,既不注入也不调用 `ctx.fs`:映射中无条目即未见,`absent` 表示确认缺失,`present(version)` 是替换/编辑基准。写入把未见和缺失映射到现有 `createIfAbsent` 意图,把存在映射到 `replaceIfVersion`。编辑把未见映射到 `FS_NOT_OBSERVED`,把缺失映射到 `FS_NOT_FOUND`,把存在映射到其版本守卫。成功创建或变更后,系统会用其产生的存在版本取代缺失状态。
|
||||
|
||||
每个提供方都必须在发布点执行 `createIfAbsent`,不能只在初始探测时执行。`dsh-fs-local` 在私有同级目录中暂存并执行 fsync,再通过硬链接把暂存文件发布到目标位置;目标已存在时,不替换链接会失败,并保留竞争创建者写入的文件。`dsh-fs-e2b` 使用远程 `ln` 返回明确的已创建/已存在结果,并根据不可取消提交前取得的元数据推导已提交目标的版本。替换操作和裸无条件写入仍沿用现有发布路径。
|
||||
|
||||
本决策不宣称 `replaceIfVersion` 具有跨进程线性一致性:提供方的版本检查与替换仍只能防范被其自身锁纳入协调的写入方,以及能通过元数据检测到的写入方。更窄的保证边界准确且足以支持缺失恢复:带防护的创建绝不会覆盖在发布前出现的目标。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **读取返回未找到时删除缓存版本。** 不予采用,因为这会混淆未见与确认缺失,无法让 edit 返回正确的 `FS_NOT_FOUND` 结果,还会抹去该事件本应传达的状态转换。
|
||||
- **让 `dsh-fs-policy` 在选择意图前调用 `stat`。** 不予采用,因为这会让只依赖事件的策略转而依赖提供方,为每次决策增加 I/O,并且在发布前仍留下 TOCTOU 间隙。
|
||||
- **允许 `replaceIfVersion` 在目标消失后执行创建。** 不予采用,因为存在观测是执行替换而非创建的依据;静默改变该提供方意图会绕过必须针对缺失目标执行的重新读取,并削弱陈旧保护。
|
||||
- **让删除目标后的死路继续保持 fail-closed。** 不予采用,因为这样会使面向模型的恢复指令失实,而且正常的外部清理操作无法在会话内恢复。
|
||||
|
||||
## 影响
|
||||
|
||||
尚未观测到外部删除时,第一次变更仍以 `FS_STALE_VERSION` 失败;用户或模型必须遵循现有的重新读取恢复指令。该次针对缺失目标的重新读取会返回 `FS_NOT_FOUND` 并同时改变策略状态,此后 edit 仍被禁止,而 write 可以重新创建该路径。如果另一个写入方赢得创建竞态,本次重试会返回 `FS_NOT_OBSERVED`,并保留获胜方写入的文件。
|
||||
|
||||
观测载荷是由包拥有的事件约定变更,因此所有生产方、监听器、不变式、生成的 Cordis 目录、子系统文档以及两套文件系统工具都必须同步更新。策略保留[事件门禁决策](../architecture/2026-06-26-file-context-as-event-gate.md)确立的 read 一次 `stat`、write/edit 零次 `stat` 预算、所有者隔离、dispose 行为和可选部署边界。
|
||||
|
||||
组装后的文件系统快照固定面向模型的恢复链;提供方测试则在暂存后注入一个创建者,以证明发布不会覆盖竞争目标。[带防护变更恢复指令决策](../feature/2026-08-03-fs-tool-error-remedy.md)仍然拥有面向模型的恢复措辞;本 Agent Note 使其中的删除路径能够生效。
|
||||
@@ -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 .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md
|
||||
2026-08-03-fs-tool-error-remedy.md: f227c31365725652b130e097d70c79d3daab3684
|
||||
2026-08-03-fs-tool-error-remedy.zh.md: 6bb0d0b6ffcda282db67dd1a54f0074b3be5c6e1
|
||||
2026-08-03-fs-tool-error-remedy.md: c4f0bb86100a4856c536edf22e520662e19ea4f6
|
||||
2026-08-03-fs-tool-error-remedy.zh.md: 88309bd630732794dfb2401b60ad799a3d63146b
|
||||
|
||||
@@ -29,4 +29,4 @@ In `edit.ts` the `fs/edit-intent` waterfall now sits inside the same `try` as th
|
||||
|
||||
Model-visible text for the two codes changes; the `fs-policy-reject` keyless snapshot is re-recorded, and the READMEs of `dsh-tool-fs` and `dsh-fs-policy` pin the exact appended text. Unit tests cover the wrapper directly (remedy text, code preservation, cause chaining, passthrough of other codes and non-`FsError` values) and the assembled tool paths assert the remedy reaches the model for both codes.
|
||||
|
||||
The remedy is not a promise: a deleted observed target cannot be unblocked, because re-reading a missing file fails with `FS_NOT_FOUND` and records no observation. That dead end is pinned fail-closed in the integration tests — the retried mutation fails identically until the target exists again and is freshly observed.
|
||||
The [filesystem absence-observation follow-up](../bug-fix/2026-08-09-filesystem-absence-observation.md) makes the stale remedy actionable for external deletion. The failed reread still returns `FS_NOT_FOUND`, but records confirmed absence: edit then returns `FS_NOT_FOUND` without another stale remedy, while write retries as an atomic `createIfAbsent` and preserves any concurrent creator.
|
||||
|
||||
@@ -29,4 +29,4 @@ Status: implemented
|
||||
|
||||
两个错误码的模型可见文本发生变化;`fs-policy-reject` 无密钥快照被重新录制,`dsh-tool-fs` 与 `dsh-fs-policy` 的 README 逐字固定追加后的文本。单元测试直接覆盖包装层(恢复指令文本、错误码保留、cause 链、其他错误码与非 `FsError` 值的透传),组装后的工具路径断言两个错误码的恢复指令都到达模型。
|
||||
|
||||
恢复指令不是承诺:已观察但被删除的目标无法被解除阻塞,因为重新读取缺失文件会以 `FS_NOT_FOUND` 失败且不记录观察。这一死胡同在集成测试中以 fail-closed 方式固定——在目标重新存在并被重新观察之前,重试的变更以相同方式失败。
|
||||
[文件系统缺失观测后续决策](../bug-fix/2026-08-09-filesystem-absence-observation.md)使外部删除场景下的陈旧恢复指令能够生效。失败的重新读取仍返回 `FS_NOT_FOUND`,但会记录确认缺失:随后 edit 返回 `FS_NOT_FOUND`,不再附加陈旧恢复指令;write 则以原子 `createIfAbsent` 重试,并保留任何并发创建者写入的文件。
|
||||
|
||||
Reference in New Issue
Block a user