feat/agent-discovery-wiring:把「探机器的引擎」接到 daemon 的 provider 点名上

eyrie · PR #86 → main · 本走读只覆盖「我们的接线」(+827/−30,不含引擎本体)· 自包含,读完即弃

1 commit(squash 后)
25 文件
+827 / −30
56% 是测试代码
不含颜若仙的引擎本体

写法说明:本文只走读「我们」的接线(颜若仙交付的 fact-only 探测引擎本体在 base 分支里、已另行审过,不在本报告范围)。每个机制都给真实代码片段(取自 417be82、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释)。每条旅程结尾有「排查路标」:将来出问题,症状对应去哪个文件看哪个函数。

1TL;DR

daemon 里有个引擎能探出「这台机器装没装 claude、装在哪、什么版本」,但之前全系统没一处用它——它是死代码。本次接线把这条线接通:让 provider 的「点名清单」(detectAll) 从假信号变成真安装状态,把「在用的是哪个安装」持久化进数据库,并在那个安装后来被删/挪走时报异常而不是闷声改用别的,最后通过 tRPC 透给前端。

贯穿原则一句话:引擎只报客观事实,所有「这对 Eyrie 意味着什么」的政策都留在 daemon 地基。所以引擎一行没改,全部改动是地基侧的「消费 + 政策」。

2变更地图(称重)

825 行净增里,真正的逻辑只有 ~366 行生产代码,其余是测试(密度高,比代码还多)。生产代码里又有一大半是契约/装配样板,真正承载设计判断的是一个新文件 provider-detection.ts(漂移政策)和 service.ts 的探测编排。

tests
+461 · 11 文件
daemon/agent
+272 / −23 · 6 文件
daemon 其他
+48 / −6 · 3 文件
packages/api
+37 · 4 文件
scripts
+9 · 1 文件
设计重心(要细读)可放心略过
provider-detection.tsbuildDetectionSnapshot 漂移政策(+132,全新,本 PR 唯一有真实设计判断的地方) packages/api 的 dto/services/trpc/index:纯加性契约样板,结构镜像 daemon 类型
service.ts 的探测编排(sweep / detectProvider / refresh,+73) testing/index.ts、各 stub 测试文件:补两个新方法的空实现,机械
registry.ts 的读侧合并(−20 删旧的只读 version 解析,换成读整套快照) index.tsstartAgentService helper:主要为过 max-statements,逻辑等价搬运

3架构一图流

本质变化是把一条死链接通,并刻意拆成快慢两条路。引擎产「事实」,但事实之前没有任何消费者;现在它被接到一个「慢路」(启动 + 手动刷新时 spawn 进程探测,落库),点名走「快路」(高频、纯读那条落库的快照),中间用数据库列解耦——保证高频的点名永远不会因为去 spawn 进程而变慢。

以前 · 引擎是死代码

DiscoveryEngine
没有任何调用方
(无消费者)
detectAll
available = enabled && 有 adapter(假信号)
前端
agent_info_json
有解析器但没人写这列
version(永远空)

现在 · 两条路 + DB 列解耦

引擎 reprobe
慢路:启动 sweep + 手动 refresh
agent_info_json
agent_info_json
快路:detectAll 纯读 + 合并
前端

4数据与状态先行

下沉三个形状,后面旅程会反复用到。先只看形状,不讲行为。

① 引擎吐出来的事实(来自 base,未改,先认词)

引擎每次探一个 CLI 产一个 DiscoveryResult:纯事实,没有任何「可用吗」的判断。关键字段:status(installed / not-installed / errored,探测出错跟确实没装故意分开)、candidates[](按 PATH 优先级排的命中,每个带 primary=引擎会选的那条、statuspathversion)、versionStatusprobedAt

② 我们要落库的快照(新增)

apps/daemon/src/agent/types.ts新增
// 落进 agent_providers.agentInfoJson 的快照,registry 读回来拼 availability。daemon 内部,不出 @eyrie/api。
export type ProviderDetectionSnapshot = {
  installStatus: ProviderInstallStatus   // installed | not-installed | errored | unknown
  version?: string
  binaryPath?: string                    // 「在用安装」的路径 —— 这是锚点,见旅程 A.3
  detectedAt: number
  reason?: string                        // 为什么降级(漂移原因等)
}

③ 点名清单的条目(扩展)

AgentAvailability 加了 4 个可选字段,老的 available 一个字没动。注意 installStatusavailable两个独立信号:前者答「机器上装了没」,后者答「配好了能选吗」。

apps/daemon/src/agent/types.tsAgentAvailability 增量
  available: boolean                     // 配置 + 启用 + 有 adapter —— 不代表二进制装了
  version?: string
  installStatus?: ProviderInstallStatus  // 诚实的安装信号,跟 available 分开
  binaryPath?: string
  detectedAt?: number                    // 给 UI 显示「上次检测时间」
  reason?: string

为什么不把「装了没」塞进 available:一旦合并,前端就分不清「没装 / 装了但没启用 / 探测出错」三种完全不同的处境,用户看到「不可用」无从下手(去装?开开关?重试?)。

5旅程 A:探测 → 落库(写路,慢)

这条旅程从 daemon 启动走到「这台机器现在的安装事实被写进数据库」。走通后你会知道:探测在什么时机发生、一个 provider 炸了会不会带塌别的、漂移政策具体怎么判、为什么落库不会冲掉用户配置。

全景 · 涉及 4 个文件
装配 + 甩出去探
index.ts
后台 sweep
service.ts
探单个 + 三道闸
service.ts
漂移政策(锚点)
provider-detection.ts
窄写落库
drizzle-repository.ts

A.1启动即把探测甩出去,不等它

daemon 启动时把整套 agent 栈(registry / runner / 探测引擎 / service)装配起来,然后调一次 startProviderDiscovery()。关键设计:探测是 fire-and-forget——不让开机延迟挂在「spawn 一堆 --version」上。引擎在这里被 new 出来注入 service,并登记在返回值里好让 shutdown 收摊。

apps/daemon/src/index.tsstartAgentService(新抽出的装配 helper)
const discoveryEngine = new DiscoveryEngine()
const agentService = new AgentService(
  agentRegistry, agentRepo, agentBroadcaster, runnerManager,
  discoveryEngine,                       // 第 5 个构造参数,可选
)
await agentService.onDaemonStart()
// 启动时后台探一次 CLI presence:detectAll 在此期间照旧返回上次落库的快照,
// 所以开机延迟永远不依赖去 spawn 外部 CLI。
agentService.startProviderDiscovery()
return { agentService, runnerManager, discoveryEngine }

shutdown 里多了一行 discoveryEngine.dispose(),排在关库之前。引擎参数是可选的:不传引擎,所有 provider 的 installStatus 停在 unknown——这让测试和降级场景都不依赖真引擎。

A.2一个 provider 一个 provider 地探,谁炸都不连累

startProviderDiscovery 只是个不阻塞的外壳:没引擎就直接返回,有引擎就把真正的 sweep 甩出去并吞掉异常。sweep 本身用 Promise.all 并发探所有启用的 provider,每个单独 .catch,所以一个 provider 探测抛错不会让整轮 sweep 挂掉。

apps/daemon/src/agent/service.ts三个方法叠成的甩手掌柜
startProviderDiscovery(): void {
  if (!this.discovery) return
  void this.sweepProviderDetection().catch(() => {})   // 甩出去,不 await,吞异常
}

private async sweepProviderDetection(): Promise<void> {
  const rows = await this.repo.listProviders({ enabled: true })
  await Promise.all(rows.map((row) => this.detectProvider(row).catch(() => {})))  // 每个隔离
}

真正干活的是 detectProvider,它先过三道闸才肯探:有引擎、是合法 provider kind、且这个 kind 在引擎的 catalog 里。acp 这种没进 catalog 的直接跳过——它的 installStatus 就停在 unknown,不假装探过。

apps/daemon/src/agent/service.tsdetectProvider
const DISCOVERY_KINDS: ReadonlySet<string> = new Set(CLI_CATALOG.map((e) => e.kind))

private async detectProvider(row: AgentProviderRow): Promise<void> {
  const engine = this.discovery
  if (!engine || !isAgentProviderKind(row.kind) || !DISCOVERY_KINDS.has(row.kind)) return
  const result = await engine.reprobe(row.kind)               // ← 唯一 spawn 进程的地方
  const snapshot = buildDetectionSnapshot(parseDetectionFields(row.agentInfoJson), result)
  await this.repo.updateProviderDetection(row.id, {            // 折好的快照走窄写落库
    agentInfoJson: JSON.stringify(snapshot),
    capabilitiesUpdatedAt: snapshot.detectedAt,
  })
}

注意它喂给 buildDetectionSnapshot 的第一个参数是从这行当前的 agentInfoJson 解析出的「上次快照」——这就是漂移判断的「旧结论」来源。

A.3漂移政策:把 binaryPath 当锚点(本 PR 的心脏)

这是唯一有真实设计判断的地方。要解决的问题:用户机器上可能装了好几个 claude(比如 169/170/171),系统记住了在用的是 169;后来 169 被删了或挪了位置,该报异常,而不是闷声改用 170

实现把持久化的 binaryPath 当成锚点(在用安装):首次探到时定一次,之后每次探测不覆盖它,只问一个问题——「锚点那条路径还在这次的候选里吗」。入口三分支:

apps/daemon/src/agent/provider-detection.tsbuildDetectionSnapshot 入口
export function buildDetectionSnapshot(
  previous: Partial<ProviderDetectionSnapshot>,
  result: DiscoveryResult,
): ProviderDetectionSnapshot {
  const okCandidates = result.candidates.filter((c) => c.status === 'ok')
  const anchor = previous.binaryPath
  if (anchor === undefined) return firstDetection(result, okCandidates)        // 没锚点 → 首探
  const anchored = okCandidates.find((c) => c.path === anchor)
  if (anchored) return anchorPresent(previous, result, anchor, anchored.version) // 锚点还在 → 保持
  return anchorDrifted(previous, result, anchor, okCandidates.length > 0)        // 锚点没了 → 漂移
}

首探:之前没追踪过任何安装,就采纳引擎的 primary 当锚点。没有旧锚点也就无所谓漂移,唯一可能的降级是「装着但 --version 读不出」。

apps/daemon/src/agent/provider-detection.tsfirstDetection
const primary = okCandidates.find((c) => c.primary)
const snapshot: ProviderDetectionSnapshot = { installStatus: result.status, detectedAt: result.probedAt }
const version = primary?.version ?? result.version
if (version !== undefined) snapshot.version = version
if (primary) snapshot.binaryPath = primary.path        // 锚点在这里定下,仅此一次
if (result.status === 'installed' && versionProbeFailed(result.versionStatus)) {
  snapshot.reason = 'version-probe-failed'
}

锚点还在:保持锚点不变,顺手刷新版本。binaryPath 始终写 anchor(旧值),不是这次探到的 primary。

漂移(锚点没了):这是最关键的一支。installStatus 标成 erroredbinaryPath 仍指向那条没了的锚点(不改道)、带上原因。因为锚点不动,下一轮探测还会判定「不在」→ 原因每一轮都报,不会被洗掉。有别的可用安装就标 install-path-changed(挪了),一个都没有标 previous-install-missing(没了)。

apps/daemon/src/agent/provider-detection.tsanchorDrifted
const snapshot: ProviderDetectionSnapshot = {
  installStatus: 'errored',
  binaryPath: anchor,                    // 锚点 held —— 绝不替换成新 primary
  detectedAt: result.probedAt,
  reason: hasAlternative ? 'install-path-changed' : 'previous-install-missing',
}
if (previous.version !== undefined) snapshot.version = previous.version  // 留住最后已知版本作上下文
为什么这才叫「报异常」:快照里 binaryPath 永远是你选定的那条;漂移时把状态打成 errored + 原因,是诚实地喊「你在用的那个不见了」。换到新安装只能靠用户显式选择(多版本选择 UI 本期没做)——probe 自身永不自动改道。

A.4窄写落库,碰都不碰用户配置

探测结果要存,但不能整行重写——那行还存着用户手改的 command、环境变量、密钥。所以新增一个只 set 探测两列 + updatedAt 的窄写,对比现成的整行 upsertProvider——后者会把同一行里用户手改的字段一起冲掉,这是之前的 provider 工作已经踩过的坑。

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(),             // command/args/env/secret/config/enabled 全不在 set 里
    })
    .where(eq(agentProvidersTable.id, id))
    .run()
}

这两列(agent_info_json / capabilities_updated_at)在表里早就建好等用了——所以本次无需迁移

排查路标 · 旅程 A
症状从哪下手
开机变慢 / 启动卡住index.ts startAgentService:确认 startProviderDiscovery 仍是不 await 的 fire-and-forget
某 provider 探测炸了,别的也没状态service.ts sweepProviderDetection:检查 per-row .catch 是否还在
装了但 installStatus 一直 unknownservice.ts detectProvider 三道闸 + DISCOVERY_KINDS(kind 是否在 CLI_CATALOG)
安装挪了却没报异常 / 反而被改道provider-detection.ts anchorDriftedbinaryPath 是否仍 = anchor
探测写库把用户 command 冲没了drizzle-repository.ts updateProviderDetection.set() 是否只含探测两列

6旅程 B:点名 → 前端(读路,快)

这条旅程从「任何要用 provider 的 UI 动作」走到前端拿到诚实状态。核心:读路绝不 spawn,只解析上次落库的快照。还含用户点「重新检测」的按需 refresh。

全景 · 涉及 4 个文件
detectAll 纯读合并
registry.ts
refresh(探+回读)
service.ts
tRPC list/refresh
api/trpc.ts + daemon/trpc/services.ts
前端(尚无消费者)

B.1detectAll 纯读库 + 合并快照

detectAll(高频「点名」:列 provider、开会话挑模型、配会话都走它)保持纯读库、不 spawn。本次唯一改动:拼条目时把「只读 version 的老解析器」换成「读整套快照的新解析器」,available 公式一字未改。

以前 · rowAvailability
读 provider 行
available = enabled && 有 module
...providerVersion(json) —— 只挖 version 一个字段
现在 · rowAvailability
读 provider 行
available = enabled && 有 module(没变)
...parseDetectionFields(json) —— 挖出整套快照(installStatus/path/时间/原因)

那个 parseDetectionFields防御式解析:每个字段独立 type-guard,坏 JSON / 半截 / 老的 {"version":"…"} 手写值都降级成「能读出啥读出啥」,绝不抛。删掉的旧 providerVersion 函数(−20 行)就是被它取代的。

apps/daemon/src/agent/provider-detection.tsparseDetectionFields(节选)
if (!parsed || typeof parsed !== 'object') return {}
const snapshot = parsed as Record<string, unknown>     // 当成不可信的一袋 unknown
const fields: Partial<ProviderDetectionSnapshot> = {}
if (typeof snapshot.version === 'string') fields.version = snapshot.version
if (isInstallStatus(snapshot.installStatus)) fields.installStatus = snapshot.installStatus
if (typeof snapshot.binaryPath === 'string') fields.binaryPath = snapshot.binaryPath
// …detectedAt / reason 同样逐字段守卫
return fields

细节:解析回来接受 unknown 这个状态值(比引擎吐的多一个),因为 unknown 是地基专属——引擎不认的 kind、从没探过的行。

B.2手动 refresh:唯一「先探再读」的入口

用户点「重新检测」时走 refreshProviderDetection:它是读路里唯一会触发真探测的方法——先确认 provider 存在(不在抛 notFound),探一个、落库,然后回读 detectAll 把刷新后的状态返回。

apps/daemon/src/agent/service.tsrefreshProviderDetection
const row = await this.repo.getProvider(providerId)
if (!row) throw new AppError({ code: EyrieErrorCode.provider.notFound })
await this.detectProvider(row)                          // 探 + 落库(走旅程 A 的政策)
const availability = (await this.registry.detectAll()).find(
  (entry) => entry.providerId === providerId,           // 回读,拿合并后的条目
)
if (!availability) throw new AppError({ code: EyrieErrorCode.provider.notFound })
return availability

B.3透给前端:两个新 tRPC 过程

之前前端能调的 provider 接口只有「列某 provider 的模型」。本次加 providers.list(query,列全部带安装态)+ providers.refresh(mutation,重探单个,复用现成的 provider id 输入 schema)。

packages/api/src/trpc.tsprovidersRouter 增量
list: publicProcedure.query(({ ctx }) => callService(() => ctx.services.providers.list())),
refresh: publicProcedure
  .input(providerIdInputSchema)
  .mutation(({ ctx, input }) =>
    callService(() => ctx.services.providers.refresh(input.providerId)),
  ),

daemon 侧把这两个接到 AgentService 上——providerModels: agentService,靠结构化类型直接满足。透出的 DTO 是 AgentAvailabilityDto,镜像 daemon 类型但把 kind/transport 收成 string(DTO 不引 daemon 枚举)。

排查路标 · 旅程 B
症状从哪下手
列 provider / 挑模型时 UI 卡顿registry.ts detectAll + rowAvailability:确认没引入 spawn,仍纯读
坏的 agent_info_json 把点名搞崩provider-detection.ts parseDetectionFields:try/catch 与逐字段守卫
点刷新没反应 / 报 notFoundservice.ts refreshProviderDetection:两次 notFound 检查(getProvider / detectAll.find)
前端拿不到新字段packages/api/src/dto.ts AgentAvailabilityDto + trpc/services.ts 的 list/refresh 映射

7计划 vs 实现的偏差

本 PR 是 squash 后的单 commit,但实现过程里有一处实质返工——直觉做法会留下一个隐蔽 bug,独立复审时被抓出来后改掉了。

计划/初版(直觉做法)最终做成
漂移时 binaryPath 怎么处理 每次探都把 binaryPath 覆盖成本次的新 primary,同时标 install-path-changed binaryPath 当 held 锚点,探测不覆盖它;漂移时 errored+原因、锚点不动
为什么变(这是认知裂缝所在):直觉做法有个隐蔽状态丢失——第一次探 169→170 后 binaryPath 已写成 170,下一轮 previous.binaryPath 就是 170 了,漂移判断不再触发,原因消失。等价于「闪一次提示后静默改道」,恰恰违背了「报异常不静默改道」的目标。改成锚点语义后,因为锚点不被覆盖,漂移原因每一轮都持续报,直到安装回来或用户显式重选。这处在独立复审中被发现(Major),修复后已固化了「连续两次探原因仍在」的回归测试。

另一处不算偏差但值得点出的判断:写库刻意用窄写而非现成的整行 upsertProvider——因为后者会冲掉用户手改的 command/env/secret。

8心智模型补丁

provider 的 available 就代表「这个 agent 能用」。 available 只表示「配好+启用+有 adapter」;机器上装没装看独立的 installStatus
两个信号分开,UI 才能区分 没装 / 没启用 / 探测出错 三种处境。
detectAll 听起来像「去探测一遍」。 detectAll 是纯读库 + 解析快照,从不 spawn;真探测只在启动 sweep 和手动 refresh 发生。
它高频(列 provider / 挑模型 / 配会话都走它),必须永远快。
binaryPath 是「这次探到的安装路径」。 binaryPath 是「在用安装」的锚点:首探定一次,之后探测不覆盖,漂移了也 held 着报异常。
写 provider 行就用 upsertProvider 写探测结果必须走 updateProviderDetection 窄写,否则会冲掉用户手改的 command/env/secret。
引擎不认的 provider(如 acp)会探出某种状态。 不在 CLI_CATALOG 的 kind 被三道闸挡住,installStatus 永远停在 unknown,不假装探过。

9新词表

本 PR 引入 / 赋新义的词
presence detection机器级「装没装、在哪、什么版本」的探测,区别于按需扫资产(原生 session / 命令)的另一套发现——那套不在本 PR 内。
installStatus诚实的安装信号:installed / not-installed / errored / unknown,跟 available 分开。
binaryPath(锚点)「在用安装」的路径,首探定一次后被持有,漂移检测的参照系。
drift(漂移)锚点那条安装后来不在最新候选里了(被删/挪);政策是报异常不静默改道。
ProviderDetectionSnapshot落进 agent_info_json 的探测快照形状,daemon 内部,不出 API。
DISCOVERY_KINDS引擎 catalog 里的 kind 集合,detectProvider 的第三道闸。

10测试与风险地图

测试比生产代码还多(+461 vs +366)。漂移那条本 PR 的心脏被钉得最死。

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

薄冰(无测试 / 已知遗留)

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

12覆盖声明

主力(未分发 subagent,diff < 1000 行)全量精读了本 PR 全部 25 个文件的 diff,并亲自 Read 了报告引用到的每个文件当前内容(provider-detection.tsservice.tstypes.tsindex.tsregistry.tsrepository.tsdrizzle-repository.tstrpc/services.tsdto.tsservices.tstrpc.tscheck-boundaries.mjs、两个测试文件)后裁剪代码。颜若仙的引擎本体(discovery/纳入本报告,仅引用其对外类型(DiscoveryResult 等)说明事实输入。无抽样、无略读。本报告为一次性理解辅助,不维护、不作真相源。