文件监听 · 一条因果有序的语义事件流
agent 得知道文件被改了——不管是它自己改的还是你改的。grok 的 xai-fsnotify 把 OS 的原始文件事件,整理成一条因果有序的语义流,还专门识别「git 正在操作」的窗口。这一章拆它的双监听策略、git 锁状态机与事件桥接。
文件监听 · 一条因果有序的语义事件流
workspace 要感知文件变化——你切了分支、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,内核不递归):得给每个目录单独下监听。用
ignorewalk 剪枝(处处尊重.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。
这个状态机是纯函数 + 注入 Instant——测试里不用 sleep,直接喂时间戳跑表驱动用例。「把带时间的逻辑写成纯转移函数、时间当参数传」是让并发/时序代码可测的经典手法,值得学。
桥回 hunk-tracker 与代码库图谱
监听到的变化,由 xai-grok-workspace/fs_notify.rs 的转发器分发:
.git/、.grok/ 这类隐藏目录会被过滤掉不进 hunk-tracker。HEAD 变化时,它跑 git diff --name-status ORIG_HEAD HEAD,把改动的文件精准喂给代码库图谱增量更新——超过 500 个才整体 rebuild。
进程级去重
shared(cwd) 用一个 Weak 引用的进程级注册表按规范化路径去重 OS 监听:同一个目录只开一个 watcher,最后一个订阅者 drop 了就拆掉。避免多会话对同一仓库重复监听。
和《从 LLM 到 Coding Agent》对照
那本没有文件监听——玩具 agent 假设「只有我在改文件」。grok 面对的是真实开发:你和它同时改、git 在后台动。于是它要一条语义化、去噪、识别 git 操作窗口的事件流。这又是一件「不酷但真实场景必需」的工程。下一章专门看它怎么和 git 打交道。
上一篇:workspace 抽象 · 下一篇:Git 集成