PR #112 feat/git-module:daemon 的完整 git 基座

figuretu/eyrie · 99c04e1...823adf8 · 2026-07-05 · 自包含,读完即弃

22 commits
80 文件
+15,330 / −112
48% 是测试代码
五门 verify·check·real-git 全绿

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。

1TL;DR

这个 PR 在 daemon 里从零建成完整的 git 子系统:不引入任何 git 库,全部行为 = spawn 宿主 git 二进制 + 自己解析输出。分四层——进程边界(runner:环境卫生/两阶段杀/字节上限)、纯解析(porcelain/diff/错误分类)、操作服务(status/diff/worktree/两层写锁)、反应式状态引擎(订阅者驱动的文件系统监听→防抖→重算→推送)。两大设计决策同期落地:2-A 数据模型(worktree 不建新表,是 task↔repo 绑定行的「物化态」)和 1-B handle 授权(客户端只发 server 签发的 id,不发主机路径)。它是 Eyrie「任务隔离工作目录 + 看板实时状态 + diff 审查」闭环的物理基底。

走读期间抓出并修掉一个真 bug(🔴→已修):watcher 工厂的 Linux 平台守卫抛不带 code 的裸 Error,而反应器的降级判定按 error.code 分类——真 Linux 上订阅状态流仍会崩溃,正是此前那个「降级修复」声称要治的现象(修复当时只改了判定端和注释,漏改了守卫本体;所有测试注入的都是手工带 code 的假错误,任何门禁都探不到)。且守卫前提已过时:Node ≥19.1 起 Linux 支持递归 fs.watch(本仓 runtime 下限 22.12)。修复(823adf8)= 删守卫让 Linux 真开原生监听 + 补一个驱动真工厂的测试(在 ubuntu CI 上即为真机行使)。详见旅程 A 第 3 跳。

2变更地图

15.3k 行变更里,设计承载代码高度集中在 apps/daemon/src/git/(8.9k,含每层 colocated 单测);packages/db 的 2.3k 几乎全是 drizzle 生成的迁移 snapshot。测试占全 diff 48%。称重要诚实地说:这不是一个「大而机械」的 PR——除生成物和搬运外,约 6.5k 行源码是真实设计,每层都有自己的不变式。

daemon/src/git
8,875 行 · 58%
daemon/tests
2,900 行 · 19%
packages/db
2,310 行 · 15%(2,286 为生成物)
packages/api
594 行 · 4%
daemon/src 其余
≈700 行(投影/编排/接线/级联)
配置与护栏
≈70 行(eslint/boundaries/vitest)
设计重心(要细读)可放心略过(机械)
  • git/runner.ts 进程边界(§5)
  • git/watch.ts + coalescing-publisher.ts 反应器(旅程 A)
  • git/model/error-classify.ts matcher 表(§5)
  • git/diff.ts 四道 size cap + 路径 containment(旅程 D)
  • git/worktree.ts 探针分类防误删(旅程 B)
  • git/locks.ts 两层写锁(旅程 B)
  • services/worktree-projection.ts + use-cases/worktrees.ts 2-A/1-B 落地(旅程 B)
  • db/cascade.ts + use-cases 删除路径(旅程 C)
  • tests/real-git/fixtures.ts 休眠化 fixture 体系(§13)
  • packages/db/migrations/meta/0001_snapshot.json 2,286 行生成物
  • packages/api/src/git.js 2 行运行时 shim(仓库既有惯例,main 上已有 14 个同类)
  • git/identity.ts 主体是从 services/git-identity.ts 重命名搬运(相似度 70%,真实改动见 §5)
  • packages/api 错误码三张表的纯追加
  • desktop 侧 +39 行 = 测试 mock 补齐 Services 新形状,零生产代码改动
  • test-helpers / workflow.test 的 import 路径跟随

3架构一图流

before 的 daemon 对 git 的全部能力是一个 190 行的 services/git-identity.ts(execFile 包装 + 身份探测);after 的 git 面是一条完整的分层管线,且多了一条反向的推送通道。

以前 · 只有身份探测

renderer/CLI
tRPC repos.*(仅注册/解析)
daemon services
git-identity.ts
execFile,非零 exit=异常
宿主 git
看板
无任何 git 状态通道
(不存在)

现在 · 分层 git 基座 + 推送流

客户端
git.*(11 个 procedure,只发 id)
id→path 翻译层
RepoCommonDir + 路径
GitServices 机制层
status/diff/worktree/locks
runner:spawn+卫生+杀+上限
宿主 git
fs.watch
反应器:防抖→重算→delta 推送
statusDeltas 订阅

依赖方向有护栏钉死:scripts/check-boundaries.mjs 新增规则把 apps/daemon/src/git/ 定为 daemon 私有——packages/api、packages/client、cli/desktop 的生产代码 import 它就 fail,外界只能走 client.git.* 网络契约。

4数据与状态先行

先看形状,后面的旅程直接用这些词。

4.1 两个 handle:客户端手里只有 id

packages/api/src/git.ts真实代码(节选)
/**
 * Server-vetted handle for a registered repository — the repository record id minted at registration,
 * never a host path. ...a client cannot name a repository by an arbitrary directory... */
export type RepoRef = {
  repoId: string
}

/**
 * Server-vetted handle for one worktree — the task↔repo binding id the daemon resolves to the worktree's
 * paths, distinct from and unforgeable as any host path. */
export type WorktreeKey = string   // 值 = task_repos 行 id,不是路径

整个 git 契约面只有 resolveIdentity({ dir }) 一个方法还收原始路径——它就是「注册目录」这个动作本身(此刻还没有任何库行存在),且被 filesystem-roots 白名单把守。

4.2 worktree = 绑定行的物化态(2-A)

task_repos(task↔repo 绑定表)加两列,没有新表。判别标志是 worktree_git_dir 是否非空:非空 = 这条绑定背后有一个 daemon 管理的 worktree,此时既有的 working_dir 列改指 worktree 根;删掉 worktree 就是把两列清空、working_dir 拨回主 checkout。迁移全文 3 行(两个可空 ADD COLUMN + 一个局部索引),FK 级联天然覆盖——task/project 删除时绑定行连带消失,不需要新级联。

packages/db/migrations/0001_add_task_repo_worktree.sql全文
ALTER TABLE `task_repos` ADD `worktree_branch` text;
ALTER TABLE `task_repos` ADD `worktree_git_dir` text;
CREATE INDEX `task_repos_worktree_idx` ON `task_repos` (`project_id`)
  WHERE "task_repos"."worktree_git_dir" IS NOT NULL AND "task_repos"."deleted_at" IS NULL;
  -- 局部索引只收「活跃且已物化」的行,专为反应器的同步投影服务

4.3 反应器的输入与输出

apps/daemon/src/git/watch.ts真实代码(节选)
export type WatchTarget = {
  worktree: WorktreeKey    // 发 delta 时打的 key = 客户端手里的同一个 handle
  worktreeRoot: string     // 递归 watch 的工作区根,也是重算的入参
  gitDir: string           // 本 worktree 自己的 git 目录(HEAD/index/进行中标记)
  gitCommonDir: string     // 共享 common 目录(refs/packed-refs);主 worktree 时 == gitDir
}

// 推送单位不是增量 patch,而是「哪个 worktree + 完整新快照」
GitStatusDelta = { worktree: WorktreeKey; status: GitStatusDto }

GitStatusDto 是看板要渲染的整包:branch / upstream / ahead / behind / staged / unstaged / untracked / conflicted 计数 + inProgress(merge/rebase/cherryPick/revert/bisect 五种进行中操作,靠 stat 哨兵文件零 spawn 检出)+ didHitLimit(遍历被熔断,计数是有界前缀)。

4.4 runner 的契约:非零 exit 是数据

apps/daemon/src/git/model/types.ts真实代码(节选)
export type GitRunResult = {
  stdout: Buffer            // 保持二进制,由调用方决定解码
  stderr: string
  exitCode: number          // 被信号杀死时是哨兵 -1
  signal: NodeJS.Signals | null
  truncated: boolean        // stdout 被字节上限截断——对流式读这是「有界成功」不是失败
  stderrTruncated: boolean
}

// The transport contract's repository handle is resolved to this before the mechanism runs,
// so nothing below this layer ever sees a client-supplied path.
export type RepoCommonDir = { gitCommonDir: string }

diff 侧的关键形状:DiffCompare 是 5 变体判别联合(working / staged / worktreeVsHead / range 两点 / vsBase 三点);DiffFile.status 除常规外还有 'binary' | 'tooLarge' | 'permissionChange' 三个非文本档位——「太大」是一种数据状态,不是错误。行级和文件级各有 hasBidi 位,标记内容藏了能静默重排渲染顺序的双向控制字符(Trojan-source 防御),只警示不改内容。

5底座:一次 git spawn 的生命周期 + 错误管线

所有旅程共用这两台机器,先走通一遍,后面只讲各自特有的逻辑。

5.1 spawn 前:argv 和 env 的防御性组装

daemon 是无人值守进程,任何会弹交互提示的路径(credential、GPG、SSH host key、pager)都会等一个不存在的 tty,把进程永久挂死。所以每次 spawn 前,runner 给 argv 统一加 -c 硬化前缀(写命令额外钉 gc.auto=0 防后台 gc 抢锁、commit.gpgsign=false 防 GPG 弹窗),给 env 叠一层反交互配置:

apps/daemon/src/git/runner.ts真实代码(节选)
const NON_INTERACTIVE_ENV = {
  GIT_TERMINAL_PROMPT: '0',
  GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new',
  GCM_INTERACTIVE: 'never',
  GIT_ASKPASS: '',
  GIT_PAGER: 'cat',
  LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', LANGUAGE: 'en',
  // locale 钉死英文——错误分类的 matcher 表按英文文案匹配,这是它的前提
} as const

同时剥掉三族继承 env:git 仓位族(GIT_DIR 等 8 个——daemon 可能是从 git hook 里启动的,不剥的话每个子进程都会打到宿主仓库)、配置注入族(GIT_CONFIG_COUNT/KEY_n/VALUE_n,数量开放所以按 pattern 剥)、提交身份族(GIT_AUTHOR_* 等 7 个)。读命令再加 GIT_OPTIONAL_LOCKS=0,保证读永不和用户手上的 git 抢 index.lock

--no-verify 的注入是动词白名单制而非一刀切:它是子命令选项(必须插在动词后),且 cherry-pick/revert/am 在宿主 2.39.x git 上会以 exit 129 拒绝它——盲目注入反而弄坏合法写操作:

apps/daemon/src/git/runner.ts真实代码(节选)
const NO_VERIFY_VERBS = new Set(['commit', 'merge', 'push', 'rebase'])

function buildVerbArgs(args: string[], opts: GitRunOptions): string[] {
  if (!opts.write || opts.respectHooks || args.length === 0) return args
  if (!NO_VERIFY_VERBS.has(args[0] ?? '')) return args
  return [...args.slice(0, 1), '--no-verify', ...args.slice(1)]  // 插在动词后一位
}

5.2 spawn 后:字节上限、流式 sink、三层杀

stdout/stderr 各有 16MiB 默认上限,超限时保留还装得下的前缀(git 常一个 chunk 吐一大块,整块丢会得到空 buffer)、标 truncated、立即杀进程。调用方可传 onStdout 逐 chunk 流式解析——status 服务靠它在条目数越线的那一刻中途 abort,而不是等全部输出流完。sink 抛错必须内收:它跑在 'data' 事件监听器里,逃出去就是 uncaught exception,直接打挂 daemon。

杀进程是三层防线,因为 git 会派生后代(ssh ControlMaster、smudge filter、hook 子进程),它们继承着 stdio 管道——只杀 git 本体,管道不关,close 事件永不触发,run() 永远 pending:

apps/daemon/src/git/runner.ts真实代码(节选)
// beginKill is the single chokepoint for every forced termination (cap, timeout, cancel)
const beginKill = (cause: TerminationCause | null): void => {
  // record a real cause before the once-only guard so a cancel/timeout that arrives after a
  // cap-triggered kill (which passes null) still upgrades the cause and rejects authoritatively.
  if (cause !== null) terminationCause = cause
  if (graceTimer !== undefined) return
  graceTimer = forceKill(child, opts.graceMs)       // ① SIGTERM → 宽限 → ② SIGKILL(对进程组播)
  hardDeadlineTimer = setTimeout(forceSettle, opts.graceMs + HARD_DEADLINE_MARGIN_MS)
  hardDeadlineTimer.unref()                          // ③ 后代死握管道时,硬 deadline 强制 settle
}

最终 settle 的优先级:sink 的错误 > timeout/cancel 的权威 reject > 正常 resolve。非零 exit 走 resolve——分类失败的职责整体后移到错误管线:

5.3 错误管线:stderr 考古 → 稳定错误码 → 消毒出线

git 的错误没有机器码,只能按文案「考古」。error-classify.ts 维护 15 个 matcher(稳定错误码 + 行锚定 regex + 可选操作数提取器),逐行匹配防长输出中段误命中。硬规则是消毒:上 wire 的 message 永远是错误目录渲染的句子,原始 stderr 只进结构化数据和日志。

apps/daemon/src/git/model/error-classify.ts真实代码(节选)
{
  code: EyrieErrorCode.repo.localChangesWouldBeOverwritten,
  pattern: /^error: Your local changes to the following files would be overwritten by/,
  extract: (stderr, line) => ({ files: extractIndentedFiles(stderr, line) }),
  // 操作数提取:git 点名的文件清单变成 wire envelope 的 ErrorDetail[],客户端可编程消费
},
{
  // the worktree-path error quotes the path (`fatal: '/path' already exists`); requiring the quote
  // keeps it from swallowing branch-creation's `fatal: a branch named 'X' already exists`
  code: EyrieErrorCode.repo.worktreeAlreadyExists,
  pattern: /^fatal: '.*' already exists$/,
},

matcher 表的保真由 real-git 测试层背书:每个 refusal 由活二进制驱动出来、断分类后的 code(从不断原始串、从不 pin git 版本)——git 升版改文案时测试变红,漂移被显式暴露而不是生产里静默误分类。这类回归此前咬过两次,本 PR 收口时把 detachedHead / worktreeAlreadyExists / worktreeLocked 等最后几个 matcher 也全部改为真二进制驱动。

新增一个 git procedure 的标准步骤:① packages/api/src/git.ts 加 schema/DTO(输入收 id 不收路径),注册到 trpc.ts 的 gitRouter;② trpc/services.ts 的 git block 做 id→path 翻译(resolveTarget/resolveRepoCommonDir),转发 opts.signal;③ 机制层实现收 RepoCommonDir+路径,失败走 classifyGitErrorArgs;④ git-wiring.test.ts 补转发断言,涉及真 git 行为的加 real-git 用例。

6旅程 A:看板订阅一个项目的实时 git 状态

这条旅程走通后,你就掌握了整个反应式引擎:订阅怎么把 watcher 拉起来、一次文件保存怎么变成一条 delta、以及资源不够/平台不支持时系统怎么退化而不崩。

全景 · 涉及 6 个文件
订阅入口
trpc/services.ts
生命周期 reconcile
git/watch.ts
同步投影
worktree-projection.ts
原生监听
watcher-factory.ts
防抖合并
coalescing-publisher.ts
重算(status 服务) ephemeral bus 扇出

A.1订阅那一刻:急切注册 + 同步 refresh 的时序契约

watcher 的生命周期完全由订阅者驱动:第一个订阅者来才开监听,最后一个走就拆掉。这个「谁先谁后」有严格时序——refresh 必须在订阅者注册之后跑,才能观察到 hasSubscribers === true 而启动;拆除侧反过来。接线层用了三个容易写错的手法,注释里都自证了:

apps/daemon/src/trpc/services.ts · liveStatusLog真实代码(节选)
live: (signal) => {
  // eager: registers the subscriber now so hasSubscribers flips true before the refresh below
  const iterable = underlying.live(signal)
  try {
    git?.watcher.refresh(projectId)   // 同步启动监听;失败会同步 throw 回订阅路径
  } catch (error) {
    dropEagerSubscriber(iterable)     // 必须先撤掉刚注册的订阅者再重抛,否则 hasSubscribers
    throw error                       // 卡死 true 且无消费者,watcher 永不拆除
  }
  // a single teardown refresh fired by whichever teardown path settles first; once() keeps it single
  const teardownRefresh = once(() => git?.watcher.refresh(projectId))
  // EAGERLY arm the abort teardown here, NOT inside the delegating generator's body: an ephemeral
  // subscribe opens with a resync marker the consumer commonly reads and then stops on...
  const cancelAbortRefresh = scheduleTeardownRefresh(signal, teardownRefresh)
  return delegateWithTeardownRefresh(iterable, teardownRefresh, cancelAbortRefresh)
},

这也是 resolveWorktrees 必须是同步函数的原因链:refresh 全程同步,监听挂载失败才能以同步 throw 冒回订阅路径、订阅者才能被同步撤销。生产端能同步是因为 better-sqlite3 内联读——worktree 的三个路径在创建时就物化进了 DB 行,订阅时零 git 进程:

apps/daemon/src/services/worktree-projection.ts真实代码(节选)
export function resolveProjectWorktrees(db: Db, projectId: string): ManagedWorktree[] {
  return db.select(worktreeColumns)
    .from(taskReposTable)
    .innerJoin(projectReposTable, ...).innerJoin(reposTable, ...).innerJoin(tasksTable, ...)
    .where(and(
      eq(taskReposTable.projectId, projectId),
      isNotNull(taskReposTable.worktreeGitDir),   // 已物化
      isNull(taskReposTable.deletedAt),
      isNull(reposTable.deletedAt),
      activeTaskWhere(),                           // 归档任务不进 watch 目标——归档释放活资源
    ))
    .all()
    .flatMap(...)
}

A.2一次保存到一条 delta:过滤、防抖、cancel-in-flight、指纹

每个 worktree 挂 2~3 个原生 watch(工作区递归 + gitDir + 仅 linked worktree 需要的 common dir),各管一半:工作区 watch 整体忽略 gitDir 子树,git 内部写交给 gitDir watch 的白名单(HEAD/index/packed-refs/refs/**/logs/**/五种进行中标记);index.lock 和 watchman cookie 是自激写,无条件免疫,否则 watch→重算→watch 死循环。事件落地后:

apps/daemon/src/git/watch.ts真实代码(节选)
const scheduleRecompute = (projectId, watch, debounceMs): void => {
  if (!deps.bus.hasSubscribers(projectId)) return   // 没人听就什么都不做,闲置即零开销
  // cancel-in-flight: a newer event supersedes a recompute still running... aborting here (not when
  // the queued re-run starts) is the only point before the prior recompute settles, so the superseded
  // run's git child is killed instead of finishing unread
  watch.inFlight?.abort()
  watch.publisher.schedule(() => recomputeWorktree(watch), debounceMs)
}

防抖是双窗口共用一只发布器:工作区事件 150ms(编辑器保存/checkout 扇出一大波),HEAD/ref 事件 50ms(用户期待分支切换立刻反映);发布器再叠 250ms 最小发布间隔和指纹去重(快照按渲染字段序列化,相同则整个 publish 吞掉)。单飞语义保证同一时刻至多一个重算在跑,期间的 N 个事件折叠成恰好一次排队重跑:

apps/daemon/src/git/coalescing-publisher.ts真实代码(节选)
const runCompute = async (): Promise<void> => {
  ...
  try {
    const value = await compute()
    if (disposed) return
    const next = deps.fingerprint(value)
    if (next !== lastFingerprint) {          // 指纹闸:结果没变就不重发
      lastFingerprint = next
      lastPublishAt = timers.now()
      deps.publish(value)
    }
  } catch {
    // a compute rejection ... must not escape: this runs detached behind a timer, so an unhandled
    // rejection here would crash the host process. drop the failed run — the next event reschedules
  } finally {
    running = false
    if (!disposed && rerunQueued) { rerunQueued = false; tryRun() }  // 恰好一次排队重跑
  }
}

重算完成后有一步重新打标——机制层按路径走,契约层按 handle 说话,这是 1-B 在推送流上的体现(状态流里不出现主机路径):

apps/daemon/src/git/watch.ts · recomputeWorktree真实代码(节选)
// abort is cooperative, so a recompute can resolve AFTER a newer event aborted its signal...
// Reject the superseded result rather than returning it: the publisher would otherwise publish this
// stale snapshot and advance its fingerprint/min-gap gates, throttling the fresh re-run.
if (controller.signal.aborted || watch.inFlight !== controller) {
  throw new Error('recompute superseded by a newer event')
}
watch.inFlight = null
// re-tag the path-walked snapshot onto the target's key so deltas and the no-op fingerprint name the
// worktree by its stable handle rather than the worktree root the recompute walked
return { ...status, worktree: watch.target.worktree }

A.3降级三护栏,和走读抓出的那个 bug

三种情况让一个 worktree 放弃原生监听、改为 2 秒轮询:全局句柄预算(默认 256)用完;Linux 内核 inotify 配额耗尽(ENOSPC,可恢复——轮询 interval 顺带自愈重试);平台不支持递归 watch(永久——重试永败但无害)。挂载是全有或全无:一个 worktree 的 2~3 个 watch 任一被拒就回滚兄弟句柄,绝不留「半瞎」状态。降级判定按错误码分类:

apps/daemon/src/git/watch.ts真实代码(节选)
function errorCode(error: unknown): string | null {
  return error instanceof Error && 'code' in error ? String(error.code) : null
}
function isDegradableWatchError(error: unknown): boolean {
  return isNoSpaceError(error) || isRecursiveUnsupportedError(error)
  // 即 code === 'ENOSPC' 或 code === 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM';其余错误 rethrow
}

bug 在判定的另一端。走读前的 watcher 工厂里有一个平台前置守卫,在 Linux 上先于 fs.watch 触发:

apps/daemon/src/git/watcher-factory.ts · 修复前(823adf8 之前)真实代码
const RECURSIVE_SUPPORTED = process.platform === 'darwin' || process.platform === 'win32'

function openNativeWatch(dir, onEvent): WatcherHandle {
  if (!RECURSIVE_SUPPORTED) {
    // recursive watching is non-functional here (Linux); the reactor only degrades ENOSPC to
    // polling, so this non-ENOSPC throw propagates out of the subscribe path...
    throw new Error(`recursive directory watching is unsupported on ${process.platform}`)
    // ← 裸 Error,没有 code 字段。降级判定按 code 分类,接不住它 → rethrow → 订阅崩溃。
    // 注意这段注释还是「只降级 ENOSPC」的旧叙事——降级修复只改了判定端和文件头 TSDoc,漏改了这里。
  }
  ...

三个事实叠在一起让它一直没被抓到:①所有测试(单测 + wiring 集成测)注入的都是手工构造、带 code 字段的假错误,真工厂全仓零测试触达;②守卫只在 Linux 触发,开发机是 macOS;③ ubuntu CI 的绿也只证明「反应器对带 code 的错误会降级」,不证明「真工厂在 Linux 上抛什么」。而且守卫的前提本身已过时——Node 自 19.1 起在 Linux 支持递归 fs.watch,本仓 runtime 下限是 22.12。修复是删掉守卫,让 Linux 直接开真原生监听:

apps/daemon/src/git/watcher-factory.ts · 修复后(823adf8)真实代码(节选)
// Errors are not caught here: fs.watch raises them with `code` set, the field the reactor's
// degrade predicate classifies on.
function openNativeWatch(dir: string, onEvent: (changedPath: string) => void): WatcherHandle {
  const watcher: FSWatcher = watch(dir, { recursive: true }, (_event, filename) => {
    if (filename === null) return
    const changedPath = isAbsolute(filename.toString())
      ? filename.toString()
      : resolve(dir, filename.toString())   // 相对路径归一成绝对路径,反应器的前缀过滤才能比对
    onEvent(changedPath)
  })
  watcher.unref()
  return { close: () => watcher.close() }
}

修复后的行为矩阵:现代 Node Linux → 真原生监听;巨型仓耗尽 inotify → ENOSPC(带 code)→ 降级轮询 + 自愈;真不支持递归的平台 → Node 自己抛带 code 的平台错误 → 降级轮询。配套新增 watcher-factory.test.ts 驱动真工厂(预建嵌套目录、写文件、断言事件以绝对路径到达)——它进默认套件,在 ubuntu CI 上就是 Linux 递归 watch 的真机行使,补上了此前缺失的证据环。该 commit 推上后 CI 已全绿。

排查路标 · 旅程 A
症状从哪下手
看板 git 状态列不更新watch.ts:先看 isWatching(projectId);再查事件是否被过滤(isStatusRelevantGitDirPath 白名单漏了新哨兵?)
订阅 statusDeltas 直接报错watch.tsattachNativeWatches catch:错误码不在降级名单就会 rethrow;liveStatusLog 的同步 throw 路径
状态更新明显滞后(秒级)该 worktree 可能已降级轮询:查 inotify 配额(ENOSPC)、句柄预算 openHandleCount()、poll interval 2s
delta 风暴/重复推送coalescing-publisher.ts:指纹字段是否漏了新状态字段;fingerprintStatus 与 GitStatusDto 对齐
状态卡死在旧值recomputeWorktree 的 supersede-reject 路径;watch.inFlight 是否被正确清理
项目删除后订阅挂着不动trpc/services.ts project.delete 的 gitStatusBus.closeTopic(id)

7旅程 B:为任务物化一个 worktree(创建与删除)

这条旅程是 2-A 和 1-B 两个决策的交汇点:客户端只说「给这条 task↔repo 绑定造个 worktree」,磁盘位置、真实 git 目录、库记录全部由 daemon 决定。

全景 · 涉及 5 个文件
契约(无路径)
packages/api/git.ts
编排 + 守卫
use-cases/worktrees.ts
两层锁 + git 执行
git/worktree.ts + locks.ts
读回真 gitDir
.git 文件
落库(2-A)
task_repos 三列

B.1创建:契约里没有 path 字段

packages/api/src/git.ts真实代码(节选)
/** Validates a worktree-creation request against an existing task↔repo binding; the daemon owns the
 * worktree's on-disk location, so the client names the binding to materialize, never a path. */
export const gitCreateWorktreeSchema = z.object({
  taskRepoId: z.string(),
  branch: z.string(),
  baseRef: z.string(),
})

编排层先做三道守卫(绑定不存在→404;task 已归档→拒绝,和其他 task_repos 写操作的归档守卫对称;已物化→worktreeAlreadyExists),然后算落点。布局在 EYRIE_HOME/worktrees/<projectId>/<taskId>/<repoLeaf>——刻意放在所有被管仓库外部,worktree 永远不会被仓库自己的 git status 看见或被 clean 扫掉。repo 显示名是用户可设的,叶子段要消毒防穿越:

apps/daemon/src/use-cases/worktrees.ts真实代码(节选)
async create(input) {
  const binding = loadBinding(db, input.taskRepoId)          // 四表 join,含守卫用的字段
  if (isTaskArchived({ archivedAt: binding.taskArchivedAt }))
    throw new AppError({ code: EyrieErrorCode.task.archived })
  if (binding.worktreeGitDir !== null)
    throw new AppError({ code: EyrieErrorCode.repo.worktreeAlreadyExists })
  const path = join(worktreeBaseDir, binding.projectId, binding.taskId, repoLeaf(binding.repoName))
  const info = await git.createWorktree({
    repo: { gitCommonDir: binding.gitCommonDir }, path,
    branch: input.branch, baseRef: input.baseRef,
  })
  const gitDir = await readLinkedWorktreeGitDir(path)        // 关键:不猜,读 git 写的 .git 文件
  db.update(taskReposTable).set({
    workingDir: path,                                        // 这一写就是 2-A:三列翻转 = 物化
    worktreeBranch: input.branch,
    worktreeGitDir: gitDir,
    updatedAt: now,
  }).where(eq(taskReposTable.id, input.taskRepoId)).run()
  return info
}

为什么 gitDir 必须读回来而不是按路径猜:git 会对撞名的叶子目录做去重(两个 task 物化同一个 repo 时,管理目录变成 wtwt1)——按 basename 推导的猜测会指错。.git 文件里的 gitdir: <path> 是权威。

git 执行层面,写操作套两层锁:repo 锁(key=gitCommonDir,序列化共享的 refs/objects 写)在外、worktree 锁(key=gitDir,序列化单 worktree 的 index 写)在内,固定锁序免死锁;主 worktree 的 gitDir 就是 common dir,两 key 相等时坍缩成单锁防自锁。锁内再包 *.lock 竞争的有界退避重试([0,100,300,700]ms,判据是 stderr 分类到 indexLocked)。real-git 层的招牌测试用 3 worktree × 24 并发空 commit 证明:不加锁真丢 commit、只留重试网也丢、两层全开一个不丢且零残锁。

B.2删除:探针分类,拿不准就不删

删 worktree 的危险在「rm -rf 错目标」:git 对孤儿 checkout 和从未注册过的普通目录拒绝时用同一句 fatal: ... is not a working tree。机制层(git/worktree.ts)的递进闸门:非 force 被拒一律 typed 上抛(dirty/locked/main 是 git 在保护);force 被拒且措辞不是 stale 句式也上抛;只有 force + stale 措辞才进恢复,且恢复前先做磁盘探针三分类——absent(删除是 no-op)/ worktreeCheckout(.git 是文件且 gitdir 指进 worktrees/,孤儿可删)/ ordinaryDirectory(永不删除,typed 拒绝)。编排层删除成功后把绑定复位:

apps/daemon/src/use-cases/worktrees.ts · remove真实代码(节选)
// clear the worktree record and reset the working dir to the repository's main checkout so the task
// is usable without a worktree
db.update(taskReposTable).set({
  workingDir: mainCheckoutDir(target.gitCommonDir),   // 剥掉 common dir 尾部 /.git = 主 checkout
  worktreeBranch: null,
  worktreeGitDir: null,
  updatedAt: now,
}).where(eq(taskReposTable.id, input.worktree)).run()
排查路标 · 旅程 B
症状从哪下手
创建报 worktreeAlreadyExists 但盘上没有task_repos 行的 worktree_git_dir 残值(半脱附);use-cases/worktrees.ts 的 already-materialized 守卫
创建成功但状态流不认识它worktree-projection.ts:行是否三列齐全(toManagedWorktree 会丢半记录行);task 是否已归档
删除把不该删的目录删了/该删的删不掉git/worktree.ts:classifyRemoveTarget 探针三分类;isStaleWorktreeRefusal 措辞匹配
并发写丢 commit / 残留 *.lockgit/locks.ts:withWriteLocks 锁序与等键坍缩;runWriteWithRetry 退避表
删除后 task 没了工作目录mainCheckoutDir 对 bare/非常规 common dir 返 null(git/identity.ts)

8旅程 C:硬删带 worktree 的任务——两阶段删除与终端清扫的交汇

worktree 的路径信息只活在即将被 FK 级联抹掉的 task_repos 行里,行一删路径就没了。所以删除是两阶段:事务内快照要删盘的目标 → 提交 DROP → 事务外 best-effort 删盘。DB 是成功边界,盘是尽力而为。

全景 · 涉及 5 个文件
tRPC delete 路由
trpc/services.ts
删除事务(快照+DROP)
use-cases/tasks.ts · db/cascade.ts
事务外清理
removeWorktreeDisks
git worktree remove --force(index.ts 注入)

C.1为什么快照必须在事务内:reparent 窗口

删除前有一段事务外的预清理(session 运行时 teardown),这个窗口里用户可能把子 task 拖到别的父级下(reparent)。用窗口前的旧快照删盘,就会删掉一个刚被救走的 task 的活 worktree。解法沿用 main 上已有的「事务内重走子树」纪律,worktree 快照挂进同一个事务:

apps/daemon/src/use-cases/tasks.ts · deleteTask真实代码(节选)
const dropped = deps.db.transaction((tx) => {
  const ids = collectTaskSubtreeIds(tx, id)              // 事务内重走子树,以此刻的树为准
  const sessionIds = collectSessionIdsUnderTasks(tx, ids)
  // snapshot the managed worktrees atomically with the DROP so disk cleanup targets exactly the
  // bindings the cascade removes, never a sibling task reparented out during the teardown window
  const worktrees = collectManagedWorktreeTargetsUnderTasks(tx, ids)
  hardDeleteTaskTree(tx, { ids })
  return { sessionIds, worktrees }
})
...
// Detach the dropped tasks' managed worktrees from disk after the authoritative DELETE; best-effort,
// so a worktree git refuses to remove only lingers as prunable disk rather than failing the delete.
await removeWorktreeDisks(deps.removeWorktreeDisk, dropped.worktrees)

收集查询有一个和投影方向相反的取舍:故意不滤 deleted_at——已软删(detach)但两列没清、盘还在的绑定,行马上要被 FK 连带删除,盘也该一并清。另一个防御细节在整形函数里:working_dir 为 null 的半记录行被丢弃,「a half-recorded worktree never produces a removal against an empty path」。

C.2与 #101 终端清扫的合流:一条删除管线,三种副作用

这个 PR rebase 跨过了 main 的 #101(workbench engine),两边在删除路径上各自长了钩子,合并后的 project.delete 是「同一事务收集三样、事务外各自执行」:

apps/daemon/src/use-cases/projects.ts · deleteProject真实代码(节选)
const dropped = deps.db.transaction((tx) => {
  const taskIds = collectTaskIdsUnderProject(tx, projectId)      // main 已有:给终端 pty 清扫
  const sessionIds = collectSessionIdsUnderProject(tx, projectId) // main 已有:session 清理
  const worktrees = collectManagedWorktreeTargetsUnderProject(tx, projectId)  // 本分支新增
  hardDeleteProjectTree(tx, { projectId })
  return { taskIds, sessionIds, worktrees }
})
apps/daemon/src/trpc/services.ts · projects.delete 路由真实代码(节选)
const { deletedTaskIds, ...result } = await deps.projectUseCases.delete(id, context)
// Project delete hard-drops every task row; terminal ptys are task-owned but out-of-band, so
// sweep only the tasks that belonged to this project after the authoritative delete settles.
for (const taskId of deletedTaskIds) deps.terminalRegistry.killByTask(taskId)   // #101 的钩子
// The project is gone, so both of its ephemeral topics end as a healthy end-of-stream...
boardDeltas.closeTopic(id)
gitStatusBus.closeTopic(id)                                                     // 本分支的钩子

删盘能力是可选注入(UseCaseEffects.removeWorktreeDisk):生产在 daemon 入口接到 git 机制并拍了 force: true(脏 worktree 也删,git 拒绝就当可 prune 残盘);测试 harness 不注入即 no-op;每个目标独立吞错,一个顽固 worktree 不拦其余的。归档路径零改动——archive 不碰 task_repos,worktree 留盘是「什么都没写」自然得到的语义;归档态的行为差异全在读侧(投影排除归档 task 的 watch 目标,但按 handle 的单点解析仍可读——留盘就该可读)。

排查路标 · 旅程 C
症状从哪下手
删了 task 但磁盘 worktree 还在正常优先怀疑 best-effort 吞错:use-cases/tasks.tsremoveWorktreeDisks;git 拒删的原因看 daemon 日志;残盘可 git worktree prune
删 task 误删了别的 task 的 worktree不应发生——collectManagedWorktreeTargetsUnderTasks 以事务内子树为准;task-lifecycle.test.ts 的 reparent-window 用例钉着
归档后 worktree 消失了不应发生——archive 全程不触 task_repos;若消失,查是否有人走了硬删路径
删除卡住/很慢删盘在事务外逐个串行 await;单个 git worktree remove 超时 60s(git/worktree.ts)

9旅程 D:一次 diff 读——上限、注入防护、路径 containment

diff 是查询不是搬运:任何单点都不能把 daemon 内存拉爆或把渲染器冻死。这条旅程短,但三个防御点将来排查都会用到。

① 五种比较模式的 argv。vsBase 用三点语法——比的是「这个分支自 merge-base 以来自己的工作」,不含未提交改动、不含 base 侧的分叉;revision 拼进 argv 的位置放不了 -- 分隔符(其后全被当 pathspec),所以前置守卫拒一切 - 开头的输入:

apps/daemon/src/git/diff.ts真实代码(节选)
case 'vsBase':
  // merge-base(base, HEAD)..HEAD: only this branch's own work, never base's divergent commits
  return [`${assertRevision(compare.base)}...HEAD`]

// A leading `-` is the argument-injection vector — git would treat `--output=…`/`-O…` as a flag,
// turning a read-only query into a file write — and is never a legitimate revision
function assertRevision(rev: string): string {
  if (rev.startsWith('-')) throw new AppError({ code: EyrieErrorCode.repo.badRevision })
  return rev
}

② 四道 size cap,「大」分两种。单文件 2MiB / 单行 5 万字符越线只把那个文件降为 tooLarge(保留 numstat 计数,规模仍可见);累计 128MiB 是 sticky 的——一旦越线,其后每个文本文件一律 tooLarge 且整个结果标 truncated;untracked 数量 cap(1000)防串行 spawn 风暴。另有每 spawn 256MiB 的 OOM 后备,file-list/patch 流被它切断即撕裂流,直接抛 diffTooLarge 绝不解析残片:

apps/daemon/src/git/diff.ts · sizeFileOutcome真实代码(节选)
// the cumulative cap is sticky: once a prior file crossed it, every later textual file is tooLarge
if (truncated) return { file: tooLargeFile(meta), loadedBytes, truncated: true }
const contentBytes = Buffer.byteLength(section.body, 'utf8')
if (loadedBytes + contentBytes > limits.maxTotalBytes)
  return { file: tooLargeFile(meta), loadedBytes, truncated: true }
if (exceedsFileLimits(section, contentBytes, limits))
  return { file: tooLargeFile(meta), loadedBytes, truncated }   // 单文件超帽:不置全局 truncated

③ worktree 文件读的双查 containment。按相对路径读文件必须锁死在 worktree 根内:词法检查挡 ../,但挡不住「<root>/link 是指向树外的软链」——词法在根内、打开的是树外文件。所以对磁盘上存在的最深前缀做 realpath 后再查一遍:

apps/daemon/src/git/diff.ts真实代码(节选)
async function resolveWithinWorktree(worktree: string, path: string): Promise<string> {
  const root = resolve(worktree)
  const absolutePath = resolve(root, path)
  assertLexicallyWithin(root, absolutePath)          // 第一道:词法
  const realRoot = await realpathOrSelf(root)
  const realPath = await realpathOfExistingPrefix(absolutePath)
  assertLexicallyWithin(realRoot, realPath)          // 第二道:软链解析后再查
  return absolutePath
}

已知残留:校验和 open 之间存在竞态窗口(校验后有人把组件换成逃逸软链)。收口裁决判它缓做——利用前提是攻击者已能在 worktree 内写文件,对个人本地产品该前提等于「攻击者就是用户自己」;触发条件(开源/远程形态)到了再用 O_NOFOLLOW/fd 反查收口。此外 untracked 文件因 git diff 天生看不见,用 --no-index 对每个文件单独合成 added section 补齐——exit 1 是数据,exit 128(枚举与 spawn 窗口里文件被删)只跳过该文件。

排查路标 · 旅程 D
症状从哪下手
diff 里某文件显示 tooLarge 但不大diff.ts:先分清是单文件帽还是 sticky 累计帽(看 result.truncated);限值可经 DiffSizeLimits 覆写
新建未 add 的文件在 diff 里消失readUntracked:数量 cap(1000)截断?ls-files 枚举与 spawn 的 TOCTOU 跳过?
合法 revision 被拒 badRevisionassertRevision 只拒 - 开头;其余是 git 自己拒的,看分类(bad revision/ambiguous)
读文件报 pathInaccessibleresolveWithinWorktree 双查:路径真逃逸了,还是 worktree 根本身是软链(realRoot 比对)
评论锚错位/行号漂移diff 全程 pin core.autocrlf=false-M -C(diff.ts 头部);锚定用 blobOid 而非行号

10计划 vs 实现的偏差

照计划做成的部分你已经知道了;下面是中途变卦的部分——每条都是一个认知裂缝。从技术方案、run-log、followups、open-decisions 和 21 条 commit message 里挖出,结论自包含。

计划原本是实际做成了为什么变
worktree 归 session(最早基线:worktrees/<repoHash>/<sessionId>) 三连跳:归 task → 最终连独立表都没有,是 task↔repo 绑定行的物化态(2-A) 同 task 的多个 session 本就复用工作目录,多数 session 是临时只读验证,per-session 浪费巨仓的漫长 checkout;进绑定行后 FK 级联白送
sparse checkout 是巨仓的一等性能机制 明确不做,createWorktree 走全量 checkout 用户拍板反转:agent 需要完整上下文,残缺工作区编译不过;将来只用不损上下文的手段(partial-clone/fsmonitor)优化
授权模型 A/B 两案并列(A=对每类原始路径各写规范化+白名单校验) 收敛到 B(注册表 handle),契约从 RepoRef={gitCommonDir} 改成 {repoId} 2-A 让 worktree 进了表,B 几乎白送——操作全变查表,原始路径只剩注册入口一处;先做 A 等于给马上被取代的校验写跨切面代码
removeWorktree 契约带 {path, force?},从路径反推 repo 三连跳:上溯最近存在祖先缓解 → 1-B 后彻底改形,repo 从存储行解析,identity 探针整个删掉 评审发现 path-only 救不回「主 checkout 外、目录已删」的 sibling worktree;正确形态取决于数据模型,先不仓促定契约的路线被验证正确
removeWorktree 规格:4 步非致命清理,每步吞错继续 递进闸门 + 探针三分类;非 force 被拒绝不下删;普通目录永不删 裸吞错会在 git 拒绝(脏/锁/主 worktree)后继续 rm -rf,毁掉未提交工作;按 basename 删元数据会撞名,改由 git worktree prune
runner 设计了 successExitCodes 旋钮(声明哪些非零退出算数据) 从未被读过,收口时作为死契约删除 实现走了更简单的路:一律把 exitCode 当数据返回、成功与否由服务层判断,旋钮失去存在意义
Linux watch:无计划(P0–P8 期间反应器只对 ENOSPC 降级) 计划外两连修:①降级判定收编平台错误码;②走读抓出工厂守卫抛裸 Error 接不住+前提过时,删守卫让 Linux 真开原生监听 2-A 落地让 resolveWorktrees 返回真目标,潜伏 bug 变实时崩溃;第一次修只改了判定端,fake 注入测试探不到真工厂,走读的代码推演才抓到第二半
P3 用「极小字节上限触发截断」当中途 kill 的杠杆 runner 加正经的 onStdout 逐块回调接缝,条目数越线即 abort 评审判 hack 不忠实于「流式不全缓冲」纪律,升级为一等接缝
绝对路径用 :(top,literal) 魔法前缀防误读;untracked 增删数取自 numstat --no-index 拒绝 pathspec 魔法,改用绝对路径;numstat 在 -z 下吐无路径 token 错位,改从 patch 体数行 两处都被真实 git 行为推翻——real-git 层的存在价值的直接注脚
#1 within-roots 纵深 = P1「上线真流量前应做」 降级 P2,带触发条件缓做(开源前/远程·团队形态/宣传 roots 为安全语义);折中=白名单声明处一行注释把边界写成文档化行为 个人本地产品形态下威胁模型不成立(loopback+默认白名单 '/'),1-B 后实现面已坍缩到单咽喉,成本不随时间涨

11心智模型补丁

daemon 对 git 的能力 = 一个身份探测函数;客户端想拿 git 数据只能自己想办法 daemon 有完整 git 面(status/diff/worktree/订阅流),客户端一律发 server 签发的 id,唯一收路径的入口是 resolveIdentity(注册动作本身)
找 git 能力先看 packages/api/src/git.ts 的契约,再看 trpc/services.ts 的 id→path 翻译层。
「worktree」如果存在,应该有自己的表和实体 worktree 是 task_repos 绑定行的物化态:worktree_git_dir 非空 = 存在;working_dir 双语义(无 worktree 指主 checkout,有则指 worktree 根)
查 worktree 永远从 task_repos 行出发;所有视图经 worktree-projection.ts 投影。
git 命令失败 = 抛异常,调用方 catch 后看 stderr 非零 exit 是 GitRunResult 里的数据;失败语义由 error-classify 的 matcher 表翻成稳定错误码;原始 stderr 永不上 wire
给错误加新语义 = 加 matcher + real-git 用例,不是在调用方加字符串匹配。
看板要 git 状态就轮询 状态是推的:订阅者驱动 watcher 生命周期(第一个来才开监听,最后一个走即拆),事件防抖合并后按 handle 推 delta;资源不够自动降级轮询并自愈
「没人订阅时 daemon 零 git 开销」是结构保证(hasSubscribers 闸在 scheduleRecompute 最前)。
删 task/project 只管数据库行 硬删是三种副作用的合流:终端 pty 清扫(taskIds)+ session 文件清理 + worktree 删盘,目标全部在删除事务内快照;归档则一律留盘
给删除加新副作用照这个模板:事务内收集 → 事务外 best-effort 执行 → 可选注入进 UseCaseEffects。
跑 bun run test 就算测过 git 了 默认套件全 fake;真 git 二进制的验证在 real-git 层(bun run test:real-git),手动跑、不在 CI 门里
改动 matcher/worktree 生命周期/锁之后,手动跑一次 real-git 是纪律。
EYRIE_FILESYSTEM_ROOTS 白名单约束 daemon 的一切文件访问 白名单只门控浏览/注册面;git 操作走已注册行的路径,不逐操作复查——这是文档化行为(filesystem-roots.ts 文件头),收紧白名单不追溯已注册的行
触发条件(开源/远程形态)到了再做纵深检查,实现面已坍缩到 id→path 翻译咽喉。
apps/daemon 的模块想 import 就 import apps/daemon/src/git/ 是 daemon 私有:契约包和 cli/desktop 生产代码 import 它会被 check-boundaries 拦下,外界只走 client.git.*

12新词表

契约与数据模型
handle / 1-B客户端只持有 server 签发的不透明 id,server 拿 id 查库换路径;防客户端指名任意目录
RepoRef / WorktreeKey两种 handle:前者 {repoId}(repos 表 id),后者裸 string(task_repos 行 id)
2-A / 物化 worktreeworktree 不建表,是绑定行的落盘形态;判别标志 = worktree_git_dir 非空
RepoCommonDir机制层内部的仓库标识 {gitCommonDir};handle 在翻译层换成它,机制层以下看不到客户端输入
gitCommonDir vs gitDir前者=全体 worktree 共享的 git 目录(refs 所在);后者=单个 linked worktree 自己的(commonDir/worktrees/<name>)
反应器
reactor / 反应器per-project 的「监听→合并→重算→发布」状态机(watch.ts)
coalescing publisher防抖+限流+指纹去重+单飞的四门发布原语,仓库首个通用合并发布件
single-flight / 单飞同一时刻至多一个重算在跑,期间的事件折叠成恰好一次排队重跑
cancel-in-flight新事件立即 abort 在途重算(杀 git 子进程)并拒收其迟到结果
degrade to polling / 自愈预算超限/ENOSPC/平台不支持时放弃原生监听改 2s 轮询;轮询 interval 顺带重试原生监听,恢复后自动拆 interval
自激路径重算自己引起的写(index.lock、watchman cookie),必须免疫否则死循环
runner 与解析
两阶段杀 / 硬 deadlineSIGTERM→宽限→SIGKILL(对进程组播);后代死握 stdio 管道时定时器强制 settle
matcher 表 / 操作数提取stderr 文案→稳定错误码的行锚定正则表;顺带挖出结构化数据(冲突文件清单)进 wire
porcelain v2 / -zgit status 的机器可读格式;NUL 分隔记录,路径不转义
可续传解析器分多次喂字节、跨调用记住半条记录的解析器,配合流式中途 kill 结算有界前缀
sticky truncationdiff 累计字节帽的语义:一旦越线,后续文本文件一律 tooLarge,标志不回落
blob-oid 可寻址读按对象 id 读历史内容——review 评论锚定 oid,钉死评论当时的确切字节
hasBidi / Trojan source行/文件级标记:内容藏了能静默重排渲染顺序的双向控制字符,只警示不改内容
测试
real-git tier*.real-git.test.ts 后缀的独立 vitest project,对真 git 二进制验证;不进默认套件和 CI
dormantize / 休眠化fixture 仓库把 .git 改名 _git 休眠,物化时复制+改回唤醒;身份日期钉死使 oid 跨机稳定
unreachable proxy任何属性访问都 throw 的占位依赖——没接线的依赖被摸到就炸,不静默 undefined
两层写锁repo 锁(gitCommonDir)外、worktree 锁(gitDir)内的 keyed mutex;等键坍缩防自锁

13测试与风险地图

有兜底的(行为被测试钉住)

薄冰(重要但无兜底/已知遗留)

合并前必办:无。tip 823adf8 的 ubuntu CI 已全绿(verify + commit 语言门 + PR 语言门),分支 22 commit 处于可 squash-merge 状态。合并后的头号跟进:①给 real-git 层找个 CI 挂载点(如 nightly 或 label 触发);②serve.test.ts 的超时余量。

14验收提示:别被这些吓到

15覆盖声明

本报告基于对 99c04e1...823adf8(22 commit,80 文件,+15,330/−112)的全量精读:7 个子系统精读 agent(地基层/服务层/反应器/契约接线/数据模型与删除/测试层/杂项兜底)覆盖每一行 diff,外加一个文档矿工读完 6 份随附文档与全部 commit message。报告中出现的每一段代码,均由主笔在 agent 素材之外亲自读过所在文件后亲手裁剪(watch.ts / coalescing-publisher.ts / runner.ts / worktree-projection.ts / use-cases/worktrees.ts / use-cases/tasks.ts / use-cases/projects.ts / db/cascade.ts / trpc/services.ts / diff.ts / error-classify.ts / identity.ts / watcher-factory.ts 修复前后 / packages/api/git.ts / filesystem-roots.ts / eslint.config.js / check-boundaries.mjs)。生成物(迁移 snapshot)做了抽查核对而非逐行读。走读期间发现的 watcher 工厂 bug 经主笔对代码路径的独立推演确认后修复并推送,tip 的 CI 已全绿——本报告描述的是修复后的状态。