#89 fact-only CLI discovery engine:给守护进程装一双「只报事实、不下判断」的眼睛

figuretu/eyrie · 6fc7e10...cc84312(feat/agent-discovery)· 2026-06-20 · 自包含,读完即弃

4 commits
39 文件
+2335 / −30
~46% 是测试代码
作者 Yan Xiwen

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

测试(全部)
~1078 行 · 46%
discovery/ 引擎
~796 行(src)
地基 + 接线
~413 行
tRPC / API 面
~78 行
设计重心(要细读)可放心略过
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/apiservices.ts / trpc.ts / index.ts:契约镜像,照抄 daemon 类型
discovery/engine.ts 并发合并 + dispose 中止 discovery/version.ts 一条正则提版本号,独立纯函数

3架构一图流

结构上多了一个进程角色:一台后台探测引擎,在守护进程启动时被装配进来、点火做一次全量探测,并在守护进程关闭时被 dispose。读取路径(detectAll)依旧是纯数据库读,只是现在读出来的行里多了探测写回去的几列。

以前 · 只读库,从不碰磁盘

daemon 启动
建 registry + service
AgentService
detectAll()
纯 DB 读
availability
(只有 version)
磁盘 / PATH
从不探测

现在 · 启动点火一次后台探测

daemon 启动
装配 + 点火 sweep
DiscoveryEngine
DiscoveryEngine
ports:跑 --version / stat
磁盘 / PATH
事实快照
折叠 + 漂移策略
窄写回库
detectAll()
纯 DB 读(合并快照)
availability
+ installStatus

前端这次拿到两个新入口:providers.list(读出每个启用 provider 的可用性,含持久化的探测态)和 providers.refresh(对一个 provider 重新探测一次再返回刷新后的可用性)。另外加了一条编译期边界:discovery/ 目录下的任何文件都不许 import 任何 provider 适配器——探测引擎是共享基础设施,只能「按 kind」认识 provider,不能反过来钻进某个具体适配器里。

4数据与状态先行

先把这个 PR 引入的几个形状的「样子」看清,行为留给后面的旅程。整套类型的共同主题是把「缺席」和「故障」掰成两件事:每一处状态都是三态而不是布尔。

4.1探测快照 DiscoveryResult 与它的三态们

引擎对一个 CLI 探完,产出一个 DiscoveryResult。注意它没有任何「能不能用」字段——只有装态、候选列表、版本、目录、时间戳。

apps/daemon/src/agent/discovery/types.ts真实代码(节选)
// 安装结论: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'
apps/daemon/src/agent/discovery/types.tsDiscoveryResult(节选)
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 / primaryprimary 标的是「引擎会用的那一个」——PATH 顺序里第一个 ok 候选。这个「列出全部候选、只钦点第一个能用的」结构,是后面那条 unreadable 修复的直接产物(见旅程 A)。

4.2持久化快照 ProviderDetectionSnapshot 与可用性的「诚实分裂」

地基把事实折叠成一个会落库的快照。它存进 agent_providers.agentInfoJson,是 daemon 内部结构,从不跨进 @eyrie/api

apps/daemon/src/agent/types.tsProviderDetectionSnapshot(节选)
export type ProviderDetectionSnapshot = {
  installStatus: ProviderInstallStatus  // 比引擎多一个 'unknown':没编目 / 没探过的行
  version?: string
  binaryPath?: string          // 「在用的那个安装」——锚点。漂移检测就盯着它
  detectedAt: number
  reason?: string              // 为什么探测态是降级的:漂移 / 探测故障 / 版本故障
}

最关键的认知更新在 AgentAvailability 上:availableinstallStatus 被刻意分成两件事。一句源码注释把界限钉死了——available 是「配了、启用了、有适配器撑着」,installStatus 是「磁盘上那个二进制到底找没找到」。两者无关:一个 provider 可以 available: true 同时 installStatus: 'not-installed'(配置齐全但 CLI 没装)。

apps/daemon/src/agent/types.tsAgentAvailability 新增字段(节选)
export type AgentAvailability = {
  // ...
  available: boolean           // 配了 + 启用 + 有适配器——不是「装没装」的声明
  installStatus?: ProviderInstallStatus  // 诚实的安装信号,和 available 分开
  binaryPath?: string          // 检测解析出的在用安装绝对路径
  detectedAt?: number          // 上次探测的 unix 毫秒,给「最近检查于」显示
  reason?: string              // 检测降级的原因,含「已装但探测/版本出错」
  // ...
}

这套字段原样镜像进 packages/apiAgentAvailabilityDto(多了 capabilities / models / sessionConfig 这些既有面),给前端用。DTO 里 installStatus 的联合类型 'installed' | 'not-installed' | 'errored' | 'unknown' 是手抄的,不是从 daemon 类型导入的——这是跨仓边界的常规做法。

5底座:探测引擎的两条公共机制

两条旅程(启动 sweep、前端 refresh)都从同一个引擎下潜。下潜前先建立两个公共认知:所有外部接触都走「端口」,以及引擎怎么管并发与生命周期。

5.1四个可替换端口:为什么要这层抽象

引擎不直接调 execFilefs,而是把所有「跨出自己进程」的动作收进四个端口:跑命令、stat 文件、读时钟、读平台事实。测试给这四个全塞假货,于是探测能在任何 CI 机器上跑——哪怕那台机器根本没装 Claude / Codex。这就是这层抽象存在的理由:探测结果不再取决于跑测试的机器上恰好装了什么。

apps/daemon/src/agent/discovery/ports.tsDiscoveryPorts(节选)
export type DiscoveryPorts = {
  command: CommandRunner   // 跑 --version,唯一真正需要子进程的缝
  files: FileProber        // stat 文件/目录,从不打开或读内容
  clock: Clock             // 供给探测时间戳
  platform: PlatformInfo   // 平台事实:PATH 各项、home 目录、环境变量
}

PlatformInfo 是个快照值对象,不是实时查询——一个假平台就能在测试里完全决定跨操作系统的路径解析(假装自己是 linux、home 在 /home/tester、PATH 是某两个目录)。命令运行器有个克制的契约:非零退出和超时都resolve 而不是 throw,把「进程跑挂了」变成一个可分类的结果而不是要 catch 的异常。

新增一个可探测 CLI 的标准步骤:只动 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 进程。

apps/daemon/src/agent/discovery/engine.tsreprobe + dispose(节选)
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 各自堵的是哪个洞。

全景 · 涉及 3 个文件
编排
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 项都留着,按优先级排,各自打 okerrored(带原因)。真正缺席的(stat 返回 null)才丢弃。

apps/daemon/src/agent/discovery/probe.tsscanCandidates(节选)
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。坑是这样的:claudecodex 装在 PATH 上几乎总是一个软链,指向某个带版本号目录里的真实可执行文件(npm / Homebrew 的常规)。原来用 lstat,看到的是链接节点本身(isFile=false),于是一个装好好的 CLI 被报成 not-installed。

改法:用跟随最终软链的 stat,让可执行性检查看到链接目标。但这又带出一个细节——ENOENT 对「真不存在」和「悬空软链」是同一个错误码。于是在 ENOENT 上回落到 lstat 把两者掰开:悬空软链在磁盘上仍然存在,那是个探测故障(一个坏掉的安装),绝不是缺席。

apps/daemon/src/agent/discovery/ports.tsnodeFileProber.stat(节选)
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 也没有 = 真缺席
  }
}
为什么这个 fix 要碰真实文件系统:假的 FileProber 复现不了——这个 bug 活在「stat 还是 lstat」这个真实选择里。所以 ports.test.ts 专门拿一个真实临时目录建软链、建悬空链、建非可执行文件来钉它。这是全 PR 里少数几个不走假端口、跑真 I/O 的测试。

A.3三态收口:把候选列表压成一个装态

候选扫完,一个三行函数把它压成 InstallStatus。顺序很关键:有 ok 就是 installed;没有 ok 但有痕迹(全是 errored)就是 errored;一片空白才是干净的 not-installed。

apps/daemon/src/agent/discovery/probe.tsinstallStatusFor
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-installeddiscovery/ports.tsnodeFileProber.stat 是否跟随了软链;对照 ports.test.ts 的软链用例
装态报 errored 但说不清为啥discovery/probe.tsscanCandidates 里每个候选的 error 字符串;installStatusFor 的三态判定
装了好几份,用的不是想要的那份discovery/paths.tsexecutableCandidates 的 PATH 顺序与去重;primary = 第一个 ok
版本号读不出来 / 读错discovery/probe.tsprobeVersion 的分类;discovery/version.ts 的正则

7旅程 B:漂移策略(前端 refresh 一个 provider)

这是全 PR 设计密度最高的一段。用户操作:在 UI 上点「刷新」一个 provider。引擎吐出新鲜事实后,buildDetectionSnapshot 决定这些事实相对于上次意味着什么——而它的全部纪律就一句话:盯着锚点,发现异常就报,永不自动改投。

全景 · 涉及 4 个文件
tRPC
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,再走一次窄写回

apps/daemon/src/agent/service.tsdetectProvider(新增,节选)
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,
  })
}

启动那条旅程(startProviderDiscoverysweepProviderDetection)复用同一个 detectProvider,只是对所有启用 provider 各探一遍。它是故意 fire-and-forget 的:探测故障在引擎里已经降级成事实,绝不该阻塞或拖垮守护进程启动;在 sweep 落定前,detectAll 一直端上次持久化的那份快照。

B.2锚点分流:buildDetectionSnapshot 的五个出口

核心函数读一个变量就分流:anchor = previous.binaryPath——上次记下的「在用安装」路径。没有锚点(第一次探)就采纳引擎的 primary 当锚点。有锚点,就按「锚点还在不在、在不在原位、还能不能用」分五路。

apps/daemon/src/agent/provider-detection.tsbuildDetectionSnapshot 主干(节选)
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

apps/daemon/src/agent/provider-detection.tsanchorDrifted(节选)
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 不会被清掉——除非锚点回到原位、或用户显式改投。一次性的警告等于「换了个马甲的静默改投」,所以这里故意让这个状态一直黏着。

直觉做法(本 PR 没采纳)
锚点 /usr/local/bin/claude 探不到了
引擎把 /opt/homebrew/bin/claude 排第一
自动改投到 /opt,状态恢复 installed
用户毫不知情,下次跑的是另一个二进制
本 PR 的做法
锚点 /usr/local/bin/claude 探不到了
引擎把 /opt/homebrew/bin/claude 排第一
保住锚点,报 install-path-changed
每次探测都报,直到锚点回来或用户改投

为什么不走直觉做法:一个被悄悄换掉的二进制,正是你最想暴露而不是隐藏的事——它可能是装错、可能是被别的东西占了名字。把异常摆出来让用户决定,比替用户做主安全。

排查路标 · 旅程 B
症状从哪下手
provider 一直报 errored / 漂移清不掉provider-detection.tsbuildDetectionSnapshot 的五路分流;看库里 agentInfoJsonbinaryPath(锚点)与现状是否对得上
装了新版却不认 / 版本号不更新锚点没动时走的是 anchorPresent,只刷新锚点版本;换了路径属于漂移,要用户显式改投(本 PR 未含改投 UI)
refresh 报 provider.notFoundservice.tsrefreshProviderDetection 对未知 / 未启用 provider 抛错;确认 row.enabled
refresh 了但库没变service.tsusesCatalogDetection 是否把这行挡在外面(自定义 command / 覆盖了 PATH)

8旅程 C:读可用性(providers.listdetectAll

读路径刻意保持「纯 DB 读」——它从不触发探测,只把库里那行(含探测写回去的列)映射成可用性。新鲜探测只发生在启动 sweep 和显式 refresh 两处。

全景 · 涉及 3 个文件
tRPC
trpc.ts · providers.list
service
service.ts · listProviders
纯读 + 映射
registry.ts · detectAll
门控合并
provider-detection.ts · usesCatalogDetection

detectAll 读启用的行、逐行映射成可用性。映射里有个门控:只有当 catalog 的 PATH 探测确实描述了这行将要启动的那个可执行文件时,才把持久化的探测字段合并进去。版本号则无条件读(它是 provider 自带的诊断数据)。

apps/daemon/src/agent/registry.tsrowAvailability(合并探测字段,节选)
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 会把用户的编辑冲掉。

apps/daemon/src/agent/drizzle-repository.tsupdateProviderDetection(新增)
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 没有 installStatusprovider-detection.tsusesCatalogDetection 把它挡了(自定义 command / 覆盖 PATH),或它根本不在 catalog(如 acp)
用户改了配置,刷新后被冲掉drizzle-repository.ts:确认走的是 updateProviderDetection 窄写,不是整行 upsert;对照 agent-repository.test 的「只写探测列」用例
list 慢 / 启动慢detectAll 是纯读不该慢;探测在后台 sweep,看 index.tsstartProviderDiscovery 是否真的 fire-and-forget

9心智模型补丁

provider 的可用性 = 配了 + 启用了,available 一个布尔说完 availableinstallStatus 是两件正交的事——可以「配齐了但 CLI 没装」
读 availability 时别再把 available: true 当成「CLI 在场」。要不要拉起来跑,得同时看 installStatus
「装没装」是个布尔,探不到就是没装 三态:installed / not-installed / errored——探测故障绝不读成「不在」
权限错误、悬空软链、非可执行文件都落在 errored,会引导用户去修而不是去重装。
探到更新的安装,就该自动用上 锚点一旦设定就一直盯着;它消失 = 每次探测都报漂移,永不自动改投
改投到新路径是用户的显式动作。漂移状态会一直黏着,直到锚点回来或用户改投。
探测时随手 execFile / fs.stat 就行 所有跨进程接触走四个 DiscoveryPorts 端口,测试全塞假货
探测结果不再取决于跑测试的机器装了什么;唯一走真 I/O 的是 symlink 那个回归测试。
写 provider 行用整行 upsert 探测写回只动两列(agent_info_json + capabilities_updated_at),用 updateProviderDetection
探测态和用户配置共用一行,整行写会冲掉用户编辑过的 command/env/secret 等。
discovery/ 想 import 哪个 provider 的工具函数都行 编译期边界禁止 discovery/ import 任何 provider 适配器
引擎是共享基础设施,只按 kind 认识 provider;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.listtRPC query:纯读出每个启用 provider 的可用性(含持久化探测态),不触发探测
providers.refreshtRPC mutation:对一个 provider 重探一次、写回、返回刷新后的可用性

11测试与风险地图

测试占了近一半篇幅,且不是凑数——漂移策略的每一条边、两个 fix 的回归、窄写不冲配置,都有用例钉住。下面左栏是「有兜底的」,右栏是「薄冰」。纯事实陈述。

✅ 有兜底的

行为钉它的测试
漂移:锚点消失 → 每次探都报 errored,永不自动改投provider-detection.test(「keeps reporting drift on every subsequent probe」)+ agent-provider-discovery.test 端到端那条
漂移愈合:锚点回到原位 → 清 reason、恢复 installedprovider-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」)

⚠️ 薄冰

🟡 启动 sweep 的 fire-and-forget 无直接测试startProviderDiscovery / sweepProviderDetection(吞掉每个 provider 的故障、不阻塞启动)这条具体路径没有单测覆盖;被测的是它复用的 detectProviderrefreshProviderDetection。「启动慢/某 provider 故障拖垮 sweep」这类问题要靠人工或集成验证。
🟡 守护进程关闭时的 dispose 顺序未端到端验证index.ts 在关闭路径里 discoveryEngine.dispose() 排在 runnerManager.disposeAll() 之前;引擎层的 dispose 行为有单测,但「真实关闭序列里有没有孤儿子进程」没有集成测试钉。
⚪ 目录变更指纹只产出、无人消费DirectoryProbe.marker(size:mtime)被算出来并塞进快照,但本 PR 没有任何读它的代码——它是给将来 fs-watch 新鲜度预留的,现在是「只有生产者没有消费者」的预留字段(见验收提示)。
⚪ Windows 原生未实现且无测试paths.ts 注释明说 PATHEXT(.exe/.cmd)扩展是预留缝、当前不实现,探测只针对 Linux/macOS。Windows 上的可执行解析行为未定义、无覆盖。

12验收提示(别被这些吓到)

13覆盖声明

本报告基于对分支 6fc7e10...cc84312(4 commits、39 文件、+2335/−30)的全量精读。所有引用的代码片段均由作者亲自 Read 对应文件后裁剪,未引用任何二手转述。