# 文件监听 · 一条因果有序的语义事件流

> agent 得知道文件被改了——不管是它自己改的还是你改的。grok 的 xai-fsnotify 把 OS 的原始文件事件，整理成一条因果有序的语义流，还专门识别「git 正在操作」的窗口。这一章拆它的双监听策略、git 锁状态机与事件桥接。

- 作者：David（道雾轩）
- 专栏：Grok Build 源码解读（https://daiw.org/manual/grok-build.md）
- 最后更新：2026-09-15
- 原文：https://daiw.org/manual/grok-build/fsnotify
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# 文件监听 · 一条因果有序的语义事件流

[workspace](https://daiw.org/manual/grok-build/workspace-abstraction) 要感知文件变化——你切了分支、rebase 了、或手动改了文件。`xai-fsnotify` 就是把 OS 的原始文件事件，整理成一条**语义化、因果有序**的流。

## 语义事件，不是原始噪声

它对外只吐几种 `FsEvent`:`FilesChanged{paths, kind}`、`GitMetaChanged{kind}`、`GitOperationStarted`、`GitOperationCompleted{head_changed}`。`.git/*` 的路径**不**混进 `FilesChanged`，而是转成 `GitMetaChanged`——把「你的代码变了」和「git 元数据变了」分开，消费者才好处理。

底层建在 `notify` + `notify-debouncer-full` 上，但特意用 **`NoCache`**：默认的 debouncer 会为了追踪文件 ID 走遍整棵树(大仓库吃几个 GB 内存)，grok 宁可不要这个、代价是重命名可能拆成 Remove+Create(下游自己处理)。

## 两种监听策略

内核对「递归监听」的支持不同，grok 分平台走：

- **Fanout**(macOS/Windows，内核递归 FSEvents/ReadDirectoryChangesW)：根非递归 + 每个顶层子目录一个递归监听(上限 64，超了就整根递归)。
- **PerDir**(Linux/inotify，**内核不递归**)：得给每个目录单独下监听。用 `ignore` walk 剪枝(处处尊重 `.gitignore`)，每个目录一个非递归监听，预算 **49152 个**(可用 `GROK_FSNOTIFY_MAX_WATCHES` 调；用完后更深的目录就不再监听)。新子树按前序补监听，还带**合成 `Created` 回填**关掉「监听装好前文件已写入」的竞态。`.git` 只**外科式**监听(`refs/heads`、`refs/tags` 等，跳过 13000+ 个 `objects/` 目录)。

新版把这个 crate 拆成 handle/selection/install/watcher 几块，边界也更清楚：它只管**单个根目录**，父仓库加各 worktree 的多根组合交给 workspace 层。

## git 操作窗口：一个锁状态机

这是我觉得最妙的部分。rebase / merge 时，git 会疯狂地建删 `index.lock`(每 pick 一次)。如果每次锁变化都当一个事件吐出去，消费者会被淹。grok 用一个**纯函数状态机**(`state.rs`)把它收敛：

`Idle → Locked → Settling → Cooldown`。关键是 **`SETTLE_MS = 500`**：锁释放后先等 500ms 再宣布「操作完成」——若窗口内又上锁(rebase 在 cycle)，就**当作同一个操作继续**(保留起始 HEAD)。于是一整轮 rebase 只吐**一对** `Started`/`Completed`，而不是几十个。完成时还比较起止 HEAD 算出 `head_changed`。

<Callout type="info">
  这个状态机是**纯函数** + 注入 `Instant`——测试里不用 `sleep`，直接喂时间戳跑表驱动用例。「把带时间的逻辑写成纯转移函数、时间当参数传」是让并发/时序代码可测的经典手法，值得学。
</Callout>

## 桥回 hunk-tracker 与代码库图谱

监听到的变化，由 `xai-grok-workspace/fs_notify.rs` 的转发器分发：

```mermaid
flowchart LR
  FS[fsnotify: FsEvent] --> FWD[事件转发器]
  FWD -->|FilesChanged| HT[hunk-tracker<br/>handle_file_change]
  FWD -->|FilesChanged| CG[codebase-graph<br/>send_event]
  FWD -->|重广播| WS[WorkspaceEvent::FsChanged]
  FWD -->|HEAD 变了| DIFF["git diff --name-status<br/>→ targeted 事件 / 超 500 则 rebuild"]
```

`.git/`、`.grok/` 这类隐藏目录会被过滤掉不进 hunk-tracker。HEAD 变化时，它跑 `git diff --name-status ORIG_HEAD HEAD`，把改动的文件精准喂给[代码库图谱](https://daiw.org/manual/grok-build/codebase-graph-build)增量更新——超过 500 个才整体 rebuild。

## 进程级去重

`shared(cwd)` 用一个 **`Weak` 引用的进程级注册表**按规范化路径去重 OS 监听：同一个目录只开一个 watcher，最后一个订阅者 drop 了就拆掉。避免多会话对同一仓库重复监听。

## 和《从 LLM 到 Coding Agent》对照

那本没有文件监听——玩具 agent 假设「只有我在改文件」。grok 面对的是真实开发：你和它同时改、git 在后台动。于是它要一条**语义化、去噪、识别 git 操作窗口**的事件流。这又是一件「不酷但真实场景必需」的工程。下一章专门看它怎么和 git 打交道。

---

上一篇：[workspace 抽象](https://daiw.org/manual/grok-build/workspace-abstraction) · 下一篇：[Git 集成](https://daiw.org/manual/grok-build/git-integration)
