#89 fact-only CLI discovery engine:给守护进程装一双「只报事实、不下判断」的眼睛
figuretu/eyrie · 6fc7e10...cc84312(feat/agent-discovery)· 2026-06-20 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。本报告只做理解,不做 code review。
1TL;DR
守护进程过去只知道「这个 provider 配了没、启没启用」,从不去磁盘上看 Claude / Codex 的命令行工具到底装没装。这个 PR 加了一台只报事实的探测引擎:它在 PATH 上找可执行文件、跑 --version 解析版本号、定位 CLI 的元数据目录,然后只汇报它看到了什么——装在哪、什么版本、哪些目录在——绝不输出「能不能用」这种判断。
判断留给上面一层「地基」。地基把事实折叠成可用性(availability)和漂移(drift)状态,落库持久化,再通过两个新 tRPC 端点(providers.list / providers.refresh)端给前端。整个设计的灵魂是一条克制的规矩:探到的安装路径一旦被锚定,就跨多次探测一直盯着它;哪天它消失了,就每次探测都报「漂移」,永远不自动改投到引擎新排出来的那个安装——改投是用户的显式动作,不是探测的副作用。
动机来自 commit 与 PR 描述:把 CLI「在不在场」这件事变成守护进程能持续观测、能持久化、能解释「为什么不对劲」的一等状态——而不是每次要用时临时 which 一下、装没装全凭运气。
2变更地图(称重)
2365 行改动里几乎没有机械搬运——没有 rename、没有 lockfile、没有等价替换。除了若干测试为适配新构造函数签名补了一两行,其余全是设计承载代码。新增文件集中在一个全新目录 agent/discovery/,地基与接线散在既有 agent 模块里。
| 设计重心(要细读) | 可放心略过 |
|---|---|
discovery/probe.ts 一次探测的完整管线 + 候选分类 |
5 个测试文件里为新构造签名补的 +1 行(agent-session-sink / agent-service-methods / agent-runner-manager / trpc-wss / test-helpers) |
discovery/ports.ts 真实 stat vs lstat 的 symlink 处理 |
discovery/index.ts 纯 barrel 导出,无逻辑 |
provider-detection.ts 漂移策略(本 PR 最微妙的一段) |
packages/api 的 services.ts / trpc.ts / index.ts:契约镜像,照抄 daemon 类型 |
discovery/engine.ts 并发合并 + dispose 中止 |
discovery/version.ts 一条正则提版本号,独立纯函数 |
3架构一图流
结构上多了一个进程角色:一台后台探测引擎,在守护进程启动时被装配进来、点火做一次全量探测,并在守护进程关闭时被 dispose。读取路径(detectAll)依旧是纯数据库读,只是现在读出来的行里多了探测写回去的几列。
以前 · 只读库,从不碰磁盘
(只有 version)
现在 · 启动点火一次后台探测
+ installStatus
前端这次拿到两个新入口:providers.list(读出每个启用 provider 的可用性,含持久化的探测态)和 providers.refresh(对一个 provider 重新探测一次再返回刷新后的可用性)。另外加了一条编译期边界:discovery/ 目录下的任何文件都不许 import 任何 provider 适配器——探测引擎是共享基础设施,只能「按 kind」认识 provider,不能反过来钻进某个具体适配器里。
4数据与状态先行
先把这个 PR 引入的几个形状的「样子」看清,行为留给后面的旅程。整套类型的共同主题是把「缺席」和「故障」掰成两件事:每一处状态都是三态而不是布尔。
4.1探测快照 DiscoveryResult 与它的三态们
引擎对一个 CLI 探完,产出一个 DiscoveryResult。注意它没有任何「能不能用」字段——只有装态、候选列表、版本、目录、时间戳。
// 安装结论:errored 故意区别于 not-installed——探测故障绝不能读成「不在」
export type InstallStatus = 'installed' | 'not-installed' | 'errored'
// 一个 PATH 候选的探测结论:可执行命中('ok') 或 在场但用不了('errored')
export type CliCandidateStatus = 'ok' | 'errored'
// 元数据目录的存在性:unreadable 是探测故障,不是缺席
export type DirectoryProbeStatus = 'present' | 'absent' | 'unreadable'
// --version 的结果:跑没跑、能不能解析、超时还是出错
export type VersionStatus =
| 'ok' | 'unparseable' | 'timeout' | 'errored' | 'skipped'
export type DiscoveryResult = {
kind: AgentProviderKind // 这个 CLI 撑哪个 provider,地基用它对上库里的行
status: InstallStatus
candidates: CliCandidate[] // 每个在磁盘上留了痕的 PATH 候选,按 PATH 优先级排
version?: string // primary 候选解析出的版本
versionStatus: VersionStatus
directories: DirectoryProbe[] // 定位到的元数据目录,只记在不在 + 变更指纹,从不读内容
probedAt: number
}
每个候选(CliCandidate)带 path / status / primary。primary 标的是「引擎会用的那一个」——PATH 顺序里第一个 ok 候选。这个「列出全部候选、只钦点第一个能用的」结构,是后面那条 unreadable 修复的直接产物(见旅程 A)。
4.2持久化快照 ProviderDetectionSnapshot 与可用性的「诚实分裂」
地基把事实折叠成一个会落库的快照。它存进 agent_providers.agentInfoJson,是 daemon 内部结构,从不跨进 @eyrie/api。
export type ProviderDetectionSnapshot = {
installStatus: ProviderInstallStatus // 比引擎多一个 'unknown':没编目 / 没探过的行
version?: string
binaryPath?: string // 「在用的那个安装」——锚点。漂移检测就盯着它
detectedAt: number
reason?: string // 为什么探测态是降级的:漂移 / 探测故障 / 版本故障
}
最关键的认知更新在 AgentAvailability 上:available 和 installStatus 被刻意分成两件事。一句源码注释把界限钉死了——available 是「配了、启用了、有适配器撑着」,installStatus 是「磁盘上那个二进制到底找没找到」。两者无关:一个 provider 可以 available: true 同时 installStatus: 'not-installed'(配置齐全但 CLI 没装)。
export type AgentAvailability = {
// ...
available: boolean // 配了 + 启用 + 有适配器——不是「装没装」的声明
installStatus?: ProviderInstallStatus // 诚实的安装信号,和 available 分开
binaryPath?: string // 检测解析出的在用安装绝对路径
detectedAt?: number // 上次探测的 unix 毫秒,给「最近检查于」显示
reason?: string // 检测降级的原因,含「已装但探测/版本出错」
// ...
}
这套字段原样镜像进 packages/api 的 AgentAvailabilityDto(多了 capabilities / models / sessionConfig 这些既有面),给前端用。DTO 里 installStatus 的联合类型 'installed' | 'not-installed' | 'errored' | 'unknown' 是手抄的,不是从 daemon 类型导入的——这是跨仓边界的常规做法。
5底座:探测引擎的两条公共机制
两条旅程(启动 sweep、前端 refresh)都从同一个引擎下潜。下潜前先建立两个公共认知:所有外部接触都走「端口」,以及引擎怎么管并发与生命周期。
5.1四个可替换端口:为什么要这层抽象
引擎不直接调 execFile 或 fs,而是把所有「跨出自己进程」的动作收进四个端口:跑命令、stat 文件、读时钟、读平台事实。测试给这四个全塞假货,于是探测能在任何 CI 机器上跑——哪怕那台机器根本没装 Claude / Codex。这就是这层抽象存在的理由:探测结果不再取决于跑测试的机器上恰好装了什么。
export type DiscoveryPorts = {
command: CommandRunner // 跑 --version,唯一真正需要子进程的缝
files: FileProber // stat 文件/目录,从不打开或读内容
clock: Clock // 供给探测时间戳
platform: PlatformInfo // 平台事实:PATH 各项、home 目录、环境变量
}
PlatformInfo 是个快照值对象,不是实时查询——一个假平台就能在测试里完全决定跨操作系统的路径解析(假装自己是 linux、home 在 /home/tester、PATH 是某两个目录)。命令运行器有个克制的契约:非零退出和超时都resolve 而不是 throw,把「进程跑挂了」变成一个可分类的结果而不是要 catch 的异常。
catalog.ts 一处——往 CLI_CATALOG 数组里加一条 { kind, command, versionArgs, directories } 数据即可。目录位置用 homeDir(...) 或 envDir(envVar, fallback, ...) 声明(后者支持像 CODEX_HOME 这样的环境变量覆盖 + home 回退)。catalog 是「怎么探」的纯数据,刻意不写成闭包,好让将来的「写」功能复用同一份声明。
5.2引擎:合并并发探测 + dispose 中止
DiscoveryEngine 包着 catalog 和 ports,对外只有 probeAll() / reprobe(kind) / dispose()。它解决的具体问题是:一阵突发的 reprobe 请求不该对同一个 CLI 同时 spawn 好几个 --version 进程。
async reprobe(kind: AgentProviderKind): Promise<DiscoveryResult> {
if (this.disposed) throw new Error('DiscoveryEngine has been disposed')
const entry = this.entriesByKind.get(kind)
if (!entry) throw new Error(`Unknown discovery CLI kind: ${kind}`)
const existing = this.inFlight.get(kind)
if (existing) return existing // 同 kind 已有在飞的探测:直接复用,不再 spawn
const probe = probeCli(entry, this.ports, options).finally(() => {
this.inFlight.delete(kind) // 落定后清缓存,下次 reprobe 是全新一轮
})
this.inFlight.set(kind, probe)
return probe
}
dispose(): void { // 中止在飞的版本命令并拦住后续探测;幂等
if (this.disposed) return
this.disposed = true
this.abortController.abort() // 共享 AbortSignal,已穿进每个版本命令
this.inFlight.clear()
}
合并的实现就是一张 inFlight: Map<kind, Promise>:探测进行中,同 kind 的并发调用拿到的是同一个 Promise;探测一落定(finally)就把缓存删掉,所以「再探一次」永远是新一轮,不会读到陈旧结果。dispose 把一个贯穿所有 --version 调用的 AbortSignal 一拉,守护进程关闭时不会有孤儿子进程吊在那里。
6旅程 A:探一个 CLI(probeCli 的内部)
这是引擎的核心动作,reprobe 最终落到这里。走通它,你就知道「装态」「版本」「目录」这三类事实分别是怎么被磁盘上的真实痕迹推导出来的——以及两个 fix commit 各自堵的是哪个洞。
probe.ts · probeCli→ 扫候选
probe.ts · scanCandidates→ stat 一个路径
ports.ts · nodeFileProber→ 跑版本→ 收口装态
probe.ts · installStatusFor
probeCli 自己是个清爽的编排:扫出候选 → 推导装态 → 对 primary 候选跑版本 → 把版本盖回 primary(让单候选和顶层一致)→ 探目录 → 组装快照。它从不 throw:每一处外部接触都被包起来,一个故障变成一个被分类的 status,而不是一次崩溃。
A.1扫 PATH 候选:记下每一个「留了痕」的路径
第一个 fix commit("stop masking unreadable installs behind a later PATH hit")改的就是这里。它的尸检报告是这样的:原来用一个共享的 errored 布尔,只要后面任何一个候选成功了,这个布尔就被丢弃——于是一个优先级更高、但读不了的安装被静默掩盖:primary 沉到一个 shell 根本不会去跑的低优先级路径上,故障从结果里彻底蒸发。
现在的写法是:每个在磁盘上留了痕的 PATH 项都留着,按优先级排,各自打 ok 或 errored(带原因)。真正缺席的(stat 返回 null)才丢弃。
for (const path of executableCandidates(entry.command, ports.platform)) {
try {
const stat = await ports.files.stat(path)
if (!stat) continue // 缺席:正常的「这儿没有」,不算痕迹
if (stat.isFile && stat.executable) {
candidates.push({ path, status: 'ok', primary: !hasPrimary })
hasPrimary = true // primary = PATH 顺序里第一个 ok,永不落在不可读路径上
} else {
// 在场但不是可运行的可执行文件——值得汇报的痕,但绝不是可用安装
candidates.push({ path, status: 'errored', primary: false,
error: 'not an executable file' })
}
} catch (error) {
// stat 故障(如权限错误)是探测故障,与缺席分开
candidates.push({ path, status: 'errored', primary: false, error: errorMessage(error) })
}
}
配套的 executableCandidates(在 paths.ts)还做了一件去重:PATH 里被列了两次的同一个目录(shell 配置常见的产物)是一个查找位置、不是两个安装,按解析后的路径去重、保留第一次,免得同一个候选被回显两遍。
A.2stat 一个路径:symlink 这一坑,和「悬空链接」的判别
第二个 fix commit("fix CLI installations missed when on PATH as symlinks")改的是 nodeFileProber.stat。坑是这样的:claude 和 codex 装在 PATH 上几乎总是一个软链,指向某个带版本号目录里的真实可执行文件(npm / Homebrew 的常规)。原来用 lstat,看到的是链接节点本身(isFile=false),于是一个装好好的 CLI 被报成 not-installed。
改法:用跟随最终软链的 stat,让可执行性检查看到链接目标。但这又带出一个细节——ENOENT 对「真不存在」和「悬空软链」是同一个错误码。于是在 ENOENT 上回落到 lstat 把两者掰开:悬空软链在磁盘上仍然存在,那是个探测故障(一个坏掉的安装),绝不是缺席。
async stat(path) {
try {
// 跟随最终软链:PATH 上的 CLI 几乎都是软链进版本化安装,
// 可执行检查必须看链接目标,不是链接节点本身。
const stats = await followStat(path)
return { isFile: stats.isFile(), executable: isExecutable(stats.mode), /* ... */ }
} catch (error) {
if (!isMissingPathError(error)) throw error
// ENOENT 既报「真缺席」也报「悬空软链」。lstat 把它们分开:
const linkStats = await lstat(path).catch(() => null)
if (linkStats) throw error // 链接还在 = 坏安装 = 探测故障,重抛去记账,绝不收成「缺席」
return null // lstat 也没有 = 真缺席
}
}
stat 还是 lstat」这个真实选择里。所以 ports.test.ts 专门拿一个真实临时目录建软链、建悬空链、建非可执行文件来钉它。这是全 PR 里少数几个不走假端口、跑真 I/O 的测试。
A.3三态收口:把候选列表压成一个装态
候选扫完,一个三行函数把它压成 InstallStatus。顺序很关键:有 ok 就是 installed;没有 ok 但有痕迹(全是 errored)就是 errored;一片空白才是干净的 not-installed。
function installStatusFor(candidates: CliCandidate[]): InstallStatus {
if (candidates.some((c) => c.status === 'ok')) return 'installed'
return candidates.length > 0 ? 'errored' : 'not-installed'
// 有痕但没一个能用 → errored;一个痕都没有 → 干净的 not-installed
}
版本探测则把「跑没跑成」也分了类:超时是 timeout、跑了但没解析出版本号是 unparseable、非零退出且无版本是 errored、没安装可探就是 skipped。版本号同时读 stdout 和 stderr——有些 CLI 把版本打到 stderr。
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 明明装了 CLI 却报 not-installed | discovery/ports.ts:nodeFileProber.stat 是否跟随了软链;对照 ports.test.ts 的软链用例 |
| 装态报 errored 但说不清为啥 | discovery/probe.ts:scanCandidates 里每个候选的 error 字符串;installStatusFor 的三态判定 |
| 装了好几份,用的不是想要的那份 | discovery/paths.ts:executableCandidates 的 PATH 顺序与去重;primary = 第一个 ok |
| 版本号读不出来 / 读错 | discovery/probe.ts:probeVersion 的分类;discovery/version.ts 的正则 |
7旅程 B:漂移策略(前端 refresh 一个 provider)
这是全 PR 设计密度最高的一段。用户操作:在 UI 上点「刷新」一个 provider。引擎吐出新鲜事实后,buildDetectionSnapshot 决定这些事实相对于上次意味着什么——而它的全部纪律就一句话:盯着锚点,发现异常就报,永不自动改投。
trpc.ts · providers.refresh→ service
service.ts · detectProvider→ 引擎探一次
engine.ts · reprobe→ 折叠 + 漂移
provider-detection.ts→ 窄写回
drizzle-repository.ts
B.1service 这一跳:探一次、折叠、窄写回
refreshProviderDetection 先取行、确认启用,再调私有的 detectProvider,最后从 detectAll() 里捞出刷新后的可用性返回。detectProvider 是接缝处:它判断这个 kind 能不能用 catalog 探(不能就原样不动),能的话探一次、把上一份快照和新事实喂给 buildDetectionSnapshot,再走一次窄写回。
private async detectProvider(row: AgentProviderRow): Promise<void> {
const engine = this.discovery
if (!engine || !isAgentProviderKind(row.kind) || !usesCatalogDetection(row)) return
const result = await engine.reprobe(row.kind)
const snapshot = buildDetectionSnapshot(parseDetectionFields(row.agentInfoJson), result)
// 上一份快照(含锚点)+ 新事实 → 新快照,漂移逻辑全在 buildDetectionSnapshot 里
await this.repo.updateProviderDetection(row.id, {
agentInfoJson: JSON.stringify(snapshot),
capabilitiesUpdatedAt: snapshot.detectedAt,
})
}
启动那条旅程(startProviderDiscovery → sweepProviderDetection)复用同一个 detectProvider,只是对所有启用 provider 各探一遍。它是故意 fire-and-forget 的:探测故障在引擎里已经降级成事实,绝不该阻塞或拖垮守护进程启动;在 sweep 落定前,detectAll 一直端上次持久化的那份快照。
B.2锚点分流:buildDetectionSnapshot 的五个出口
核心函数读一个变量就分流:anchor = previous.binaryPath——上次记下的「在用安装」路径。没有锚点(第一次探)就采纳引擎的 primary 当锚点。有锚点,就按「锚点还在不在、在不在原位、还能不能用」分五路。
const anchor = previous.binaryPath
if (anchor === undefined) return firstDetection(result, okCandidates)
const primary = okCandidates.find((c) => c.primary)
if (primary && primary.path !== anchor) return anchorDrifted(previous, result, anchor, true)
// ↑ 引擎现在把别的路径排第一了 = 漂移,但下面仍优先确认锚点本身在不在
const anchored = okCandidates.find((c) => c.path === anchor)
if (anchored) return anchorPresent(previous, result, anchor, anchored.version)
const erroredAnchor = result.candidates.find(
(c) => c.path === anchor && c.status === 'errored')
if (erroredAnchor) return anchorProbeFailed(previous, result, anchor)
return anchorDrifted(previous, result, anchor, okCandidates.length > 0)
五个出口各自给一个 reason:锚点原位还在 → 干净(清 reason);锚点没了但别处有 → install-path-changed;锚点没了别处也没 → previous-install-missing;锚点路径还在但用不了 → install-probe-failed;已装但 --version 挂了 → version-probe-failed。漂移的两个出口都保住锚点、保留上次的版本号当上下文,并把 installStatus 标成 errored。
function anchorDrifted(previous, result, anchor, hasAlternative): ProviderDetectionSnapshot {
const snapshot: ProviderDetectionSnapshot = {
installStatus: 'errored',
binaryPath: anchor, // 锚点原样保住,不换成引擎新排第一的那个
detectedAt: result.probedAt,
reason: hasAlternative ? 'install-path-changed' : 'previous-install-missing',
}
if (previous.version !== undefined) snapshot.version = previous.version
return snapshot
}
这条策略最反直觉、也最该记住的一点:漂移会在每一次后续探测上反复上报,不是只报一次。把漂移过的快照再喂回去,reason 不会被清掉——除非锚点回到原位、或用户显式改投。一次性的警告等于「换了个马甲的静默改投」,所以这里故意让这个状态一直黏着。
为什么不走直觉做法:一个被悄悄换掉的二进制,正是你最想暴露而不是隐藏的事——它可能是装错、可能是被别的东西占了名字。把异常摆出来让用户决定,比替用户做主安全。
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| provider 一直报 errored / 漂移清不掉 | provider-detection.ts:buildDetectionSnapshot 的五路分流;看库里 agentInfoJson 的 binaryPath(锚点)与现状是否对得上 |
| 装了新版却不认 / 版本号不更新 | 锚点没动时走的是 anchorPresent,只刷新锚点版本;换了路径属于漂移,要用户显式改投(本 PR 未含改投 UI) |
| refresh 报 provider.notFound | service.ts:refreshProviderDetection 对未知 / 未启用 provider 抛错;确认 row.enabled |
| refresh 了但库没变 | service.ts:usesCatalogDetection 是否把这行挡在外面(自定义 command / 覆盖了 PATH) |
8旅程 C:读可用性(providers.list → detectAll)
读路径刻意保持「纯 DB 读」——它从不触发探测,只把库里那行(含探测写回去的列)映射成可用性。新鲜探测只发生在启动 sweep 和显式 refresh 两处。
trpc.ts · providers.list→ service
service.ts · listProviders→ 纯读 + 映射
registry.ts · detectAll→ 门控合并
provider-detection.ts · usesCatalogDetection
detectAll 读启用的行、逐行映射成可用性。映射里有个门控:只有当 catalog 的 PATH 探测确实描述了这行将要启动的那个可执行文件时,才把持久化的探测字段合并进去。版本号则无条件读(它是 provider 自带的诊断数据)。
const base = {
providerId: row.id, kind: row.kind, transport: row.transport, name: row.name,
available: row.enabled && Boolean(module), // available 与「装没装」无关
...parseProviderVersion(row.agentInfoJson),
...(usesCatalogDetection(row) ? parseDetectionFields(row.agentInfoJson) : {}),
// ↑ 只有 catalog 探测对得上这行实际会跑的命令,才合并 installStatus 等字段
}
这个门控(usesCatalogDetection)挡掉两种行:一种自定义了 command(指向某个非 catalog 默认的可执行),一种在 env 里覆盖了 PATH。理由是:PATH 决定一个 provider 实际启动哪个 basename 命令,所以一旦行级覆盖了它,守护进程自己 PATH 上的探测就不再权威——在引擎能用 provider 专属 env 去探之前,这些行宁可不合并探测字段,也不给一个可能错的安装信号。
写回则是一次外科手术式的窄更新:只写 agent_info_json + capabilities_updated_at 两列,绝不碰用户编辑过的 command / args / env / secret / config / enabled。因为探测态和用户配置共用同一行 provider 行,一次整行 upsert 会把用户的编辑冲掉。
async updateProviderDetection(id: string, update: ProviderDetectionUpdate): Promise<void> {
this.db.update(agentProvidersTable)
.set({
agentInfoJson: update.agentInfoJson,
capabilitiesUpdatedAt: update.capabilitiesUpdatedAt,
updatedAt: Date.now(), // 只这三列;用户配置字段一个都不写
})
.where(eq(agentProvidersTable.id, id)).run()
}
排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| list 里某 provider 没有 installStatus | provider-detection.ts:usesCatalogDetection 把它挡了(自定义 command / 覆盖 PATH),或它根本不在 catalog(如 acp) |
| 用户改了配置,刷新后被冲掉 | drizzle-repository.ts:确认走的是 updateProviderDetection 窄写,不是整行 upsert;对照 agent-repository.test 的「只写探测列」用例 |
| list 慢 / 启动慢 | detectAll 是纯读不该慢;探测在后台 sweep,看 index.ts 的 startProviderDiscovery 是否真的 fire-and-forget |
9心智模型补丁
available 一个布尔说完
available 和 installStatus 是两件正交的事——可以「配齐了但 CLI 没装」
available: true 当成「CLI 在场」。要不要拉起来跑,得同时看 installStatus。execFile / fs.stat 就行
所有跨进程接触走四个 DiscoveryPorts 端口,测试全塞假货
agent_info_json + capabilities_updated_at),用 updateProviderDetection
discovery/ 想 import 哪个 provider 的工具函数都行
编译期边界禁止 discovery/ import 任何 provider 适配器
probe.ts 里那个 errorMessage 就是为此宁可内联也不从 Claude provider 共享。10新词表
| 探测引擎 | |
|---|---|
probe / 探测 | 对一个 CLI 跑一次完整侦察:找 PATH、跑版本、定位目录,产出一个 DiscoveryResult |
candidate / 候选 | PATH 上一个可能是该命令的路径;在磁盘上留了痕才会被记,按 PATH 优先级排 |
primary | 引擎会用的那个候选——PATH 顺序里第一个 ok(可执行)的 |
| 三态 / tri-state | 把「故障」从「缺席」里掰出来:present/absent/unreadable、installed/not-installed/errored |
| 端口 / port | 引擎跨出自己进程的四个可替换缝:command / files / clock / platform,测试可全替 |
| 元数据目录 | CLI 的 config/history/commands/sessions 目录;引擎只记在不在 + 变更指纹,从不读内容 |
| 变更指纹 / marker | 目录的 size:mtime 不透明串,能感知「动过了」却不泄露内容 |
| 地基 / 漂移 | |
| anchor / 锚点 | 「在用的那个安装」的 binaryPath,设一次、跨多次探测保持,漂移检测就盯它 |
| drift / 漂移 | 锚定的安装消失(或移位)了的状态;报 errored + reason,反复上报不自动愈合 |
| re-point / 改投 | 把锚点换到新安装——只能是用户的显式动作,绝不是探测的副作用 |
usesCatalogDetection | 门控:只有 catalog 的 PATH 探测确实描述了这行会跑的命令,才采信探测字段 |
| API 面 | |
providers.list | tRPC query:纯读出每个启用 provider 的可用性(含持久化探测态),不触发探测 |
providers.refresh | tRPC mutation:对一个 provider 重探一次、写回、返回刷新后的可用性 |
11测试与风险地图
测试占了近一半篇幅,且不是凑数——漂移策略的每一条边、两个 fix 的回归、窄写不冲配置,都有用例钉住。下面左栏是「有兜底的」,右栏是「薄冰」。纯事实陈述。
✅ 有兜底的
| 行为 | 钉它的测试 |
|---|---|
| 漂移:锚点消失 → 每次探都报 errored,永不自动改投 | provider-detection.test(「keeps reporting drift on every subsequent probe」)+ agent-provider-discovery.test 端到端那条 |
| 漂移愈合:锚点回到原位 → 清 reason、恢复 installed | provider-detection.test(「clears drift once the anchored install returns」) |
| 锚点路径还在但用不了 → install-probe-failed(区别于 missing) | provider-detection.test(「flags install-probe-failed」) |
| 窄写回不冲用户配置 | agent-repository.test(「writes only detection columns, preserving user-edited config」) |
| 自定义 command / 覆盖 PATH 的行不被 catalog 探测污染 | agent-provider-discovery.test 两条 + agent-registry.test(「keeps agent-info versions for non-catalogued」) |
| symlink 安装被识别 + 悬空软链算故障不算缺席 | ports.test(真实临时文件系统,5 条) |
| unreadable 高优先级安装不被后续 PATH 命中掩盖 | probe.test(「keeps primary on the first usable hit when a higher-priority candidate errored」「records a non-executable file as errored」) |
| 并发 reprobe 合并成一个进程;dispose 中止 + 幂等 | engine.test(coalesce / settled-then-fresh / abort / idempotent) |
| 引擎从不吐「能不能用」的判断 | probe.test(「never exposes an availability verdict」) |
| 陈旧 / 半残 / 合法 JSON 快照容错解析 | provider-detection.test(parseDetectionFields 4 条,含 legacy version-only) |
| tRPC providers 路由可服务 + Zod 拦非法输入 | trpc.test(「serves provider procedures」) |
⚠️ 薄冰
startProviderDiscovery / sweepProviderDetection(吞掉每个 provider 的故障、不阻塞启动)这条具体路径没有单测覆盖;被测的是它复用的 detectProvider 与 refreshProviderDetection。「启动慢/某 provider 故障拖垮 sweep」这类问题要靠人工或集成验证。
index.ts 在关闭路径里 discoveryEngine.dispose() 排在 runnerManager.disposeAll() 之前;引擎层的 dispose 行为有单测,但「真实关闭序列里有没有孤儿子进程」没有集成测试钉。
DirectoryProbe.marker(size:mtime)被算出来并塞进快照,但本 PR 没有任何读它的代码——它是给将来 fs-watch 新鲜度预留的,现在是「只有生产者没有消费者」的预留字段(见验收提示)。
paths.ts 注释明说 PATHEXT(.exe/.cmd)扩展是预留缝、当前不实现,探测只针对 Linux/macOS。Windows 上的可执行解析行为未定义、无覆盖。
12验收提示(别被这些吓到)
- 有"只写不读"的预留字段,不是漏接线:
DirectoryProbe.marker(目录变更指纹)和directories整块都被探测、被持久化,但当前没有任何消费者——它们是给 PR 描述里点名 deferred 的 fs-watch 新鲜度铺路的,不是忘了接。 - catalog 把版本参数写成
versionArgs数组、目录位置写成声明式数据,看着像过度设计;这是为同样 deferred 的「写」功能(写可执行路径 / provider 原生配置)让同一份声明能复用,刻意不写成闭包。 packages/api那几个文件几乎全是照抄:AgentAvailabilityDto手抄 daemon 的AgentAvailability、installStatus联合类型手写而非导入——这是跨仓边界(api 要对外自洽、不依赖 daemon 内部类型)的常规,不是重复代码失误。- acp provider 永远没有 installStatus:它不在
CLI_CATALOG里,usesCatalogDetection直接把它挡在外面,可用性里只有版本(若有)。这是预期,不是探测漏了它。 - 第一个 commit 标题带 "(#86)":那是早先已合并的「wire CLI presence detection」PR,本分支在其基础上把探测引擎补全。不是本 PR 自指。
- 没有数据库迁移:探测态复用既有的
agentInfoJson/capabilitiesUpdatedAt两列,没动 schema——所以你在 diff 里找不到 migration 文件是正常的。
13覆盖声明
本报告基于对分支 6fc7e10...cc84312(4 commits、39 文件、+2335/−30)的全量精读。所有引用的代码片段均由作者亲自 Read 对应文件后裁剪,未引用任何二手转述。
- 逐文件精读(src):
discovery/全部 9 个源文件(types/catalog/ports/paths/version/probe/engine/testing/index)+provider-detection.ts+agent/types.ts;以及registry.ts/service.ts/repository.ts/drizzle-repository.ts/index.ts/trpc/services.ts的完整 diff。 - API 面:
dto.ts/trpc.ts/services.ts/check-boundaries.mjs的完整 diff。 - 测试:全文读了
provider-detection.test与agent-provider-discovery.test;其余 7 个测试套件按 describe/it 标题与关键断言提取行为,未逐行复述。 - 两个 fix commit 的 message + stat 单独审阅,作为旅程 A「原来错在哪」的依据。
- 未独立验证:未在本机运行测试或构建(PR 描述称
bun run verify本地通过、1233 passed / 20 skipped);薄冰区的「无测试」均指未见到对应用例,非断言其必然有 bug。