feat/agent-discovery-wiring:把「探机器的引擎」接到 daemon 的 provider 点名上
eyrie · PR #86 → main · 本走读只覆盖「我们的接线」(+827/−30,不含引擎本体)· 自包含,读完即弃
写法说明:本文只走读「我们」的接线(颜若仙交付的 fact-only 探测引擎本体在 base 分支里、已另行审过,不在本报告范围)。每个机制都给真实代码片段(取自 417be82、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释)。每条旅程结尾有「排查路标」:将来出问题,症状对应去哪个文件看哪个函数。
1TL;DR
daemon 里有个引擎能探出「这台机器装没装 claude、装在哪、什么版本」,但之前全系统没一处用它——它是死代码。本次接线把这条线接通:让 provider 的「点名清单」(detectAll) 从假信号变成真安装状态,把「在用的是哪个安装」持久化进数据库,并在那个安装后来被删/挪走时报异常而不是闷声改用别的,最后通过 tRPC 透给前端。
贯穿原则一句话:引擎只报客观事实,所有「这对 Eyrie 意味着什么」的政策都留在 daemon 地基。所以引擎一行没改,全部改动是地基侧的「消费 + 政策」。
2变更地图(称重)
825 行净增里,真正的逻辑只有 ~366 行生产代码,其余是测试(密度高,比代码还多)。生产代码里又有一大半是契约/装配样板,真正承载设计判断的是一个新文件 provider-detection.ts(漂移政策)和 service.ts 的探测编排。
| 设计重心(要细读) | 可放心略过 |
|---|---|
provider-detection.ts 的 buildDetectionSnapshot 漂移政策(+132,全新,本 PR 唯一有真实设计判断的地方) |
packages/api 的 dto/services/trpc/index:纯加性契约样板,结构镜像 daemon 类型 |
service.ts 的探测编排(sweep / detectProvider / refresh,+73) |
testing/index.ts、各 stub 测试文件:补两个新方法的空实现,机械 |
registry.ts 的读侧合并(−20 删旧的只读 version 解析,换成读整套快照) |
index.ts 抽 startAgentService helper:主要为过 max-statements,逻辑等价搬运 |
3架构一图流
本质变化是把一条死链接通,并刻意拆成快慢两条路。引擎产「事实」,但事实之前没有任何消费者;现在它被接到一个「慢路」(启动 + 手动刷新时 spawn 进程探测,落库),点名走「快路」(高频、纯读那条落库的快照),中间用数据库列解耦——保证高频的点名永远不会因为去 spawn 进程而变慢。
以前 · 引擎是死代码
现在 · 两条路 + DB 列解耦
4数据与状态先行
下沉三个形状,后面旅程会反复用到。先只看形状,不讲行为。
① 引擎吐出来的事实(来自 base,未改,先认词)
引擎每次探一个 CLI 产一个 DiscoveryResult:纯事实,没有任何「可用吗」的判断。关键字段:status(installed / not-installed / errored,探测出错跟确实没装故意分开)、candidates[](按 PATH 优先级排的命中,每个带 primary=引擎会选的那条、status、path、version)、versionStatus、probedAt。
② 我们要落库的快照(新增)
// 落进 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 一个字没动。注意 installStatus 跟 available 是两个独立信号:前者答「机器上装了没」,后者答「配好了能选吗」。
available: boolean // 配置 + 启用 + 有 adapter —— 不代表二进制装了
version?: string
installStatus?: ProviderInstallStatus // 诚实的安装信号,跟 available 分开
binaryPath?: string
detectedAt?: number // 给 UI 显示「上次检测时间」
reason?: string
为什么不把「装了没」塞进 available:一旦合并,前端就分不清「没装 / 装了但没启用 / 探测出错」三种完全不同的处境,用户看到「不可用」无从下手(去装?开开关?重试?)。
5旅程 A:探测 → 落库(写路,慢)
这条旅程从 daemon 启动走到「这台机器现在的安装事实被写进数据库」。走通后你会知道:探测在什么时机发生、一个 provider 炸了会不会带塌别的、漂移政策具体怎么判、为什么落库不会冲掉用户配置。
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 收摊。
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 挂掉。
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,不假装探过。
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 当成锚点(在用安装):首次探到时定一次,之后每次探测不覆盖它,只问一个问题——「锚点那条路径还在这次的候选里吗」。入口三分支:
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 读不出」。
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 标成 errored、binaryPath 仍指向那条没了的锚点(不改道)、带上原因。因为锚点不动,下一轮探测还会判定「不在」→ 原因每一轮都报,不会被洗掉。有别的可用安装就标 install-path-changed(挪了),一个都没有标 previous-install-missing(没了)。
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 工作已经踩过的坑。
// 只写探测列,用户可编辑的配置原封不动。
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 一直 unknown | service.ts detectProvider 三道闸 + DISCOVERY_KINDS(kind 是否在 CLI_CATALOG) |
| 安装挪了却没报异常 / 反而被改道 | provider-detection.ts anchorDrifted:binaryPath 是否仍 = anchor |
| 探测写库把用户 command 冲没了 | drizzle-repository.ts updateProviderDetection:.set() 是否只含探测两列 |
6旅程 B:点名 → 前端(读路,快)
这条旅程从「任何要用 provider 的 UI 动作」走到前端拿到诚实状态。核心:读路绝不 spawn,只解析上次落库的快照。还含用户点「重新检测」的按需 refresh。
registry.ts→ refresh(探+回读)
service.ts→ tRPC list/refresh
api/trpc.ts + daemon/trpc/services.ts→ 前端(尚无消费者)
B.1detectAll 纯读库 + 合并快照
detectAll(高频「点名」:列 provider、开会话挑模型、配会话都走它)保持纯读库、不 spawn。本次唯一改动:拼条目时把「只读 version 的老解析器」换成「读整套快照的新解析器」,available 公式一字未改。
available = enabled && 有 module...providerVersion(json) —— 只挖 version 一个字段available = enabled && 有 module(没变)...parseDetectionFields(json) —— 挖出整套快照(installStatus/path/时间/原因)那个 parseDetectionFields 是防御式解析:每个字段独立 type-guard,坏 JSON / 半截 / 老的 {"version":"…"} 手写值都降级成「能读出啥读出啥」,绝不抛。删掉的旧 providerVersion 函数(−20 行)就是被它取代的。
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 把刷新后的状态返回。
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)。
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 与逐字段守卫 |
| 点刷新没反应 / 报 notFound | service.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+原因、锚点不动 |
binaryPath 已写成 170,下一轮 previous.binaryPath 就是 170 了,漂移判断不再触发,原因消失。等价于「闪一次提示后静默改道」,恰恰违背了「报异常不静默改道」的目标。改成锚点语义后,因为锚点不被覆盖,漂移原因每一轮都持续报,直到安装回来或用户显式重选。这处在独立复审中被发现(Major),修复后已固化了「连续两次探原因仍在」的回归测试。
另一处不算偏差但值得点出的判断:写库刻意用窄写而非现成的整行 upsertProvider——因为后者会冲掉用户手改的 command/env/secret。
8心智模型补丁
available 就代表「这个 agent 能用」。
available 只表示「配好+启用+有 adapter」;机器上装没装看独立的 installStatus。
detectAll 听起来像「去探测一遍」。
detectAll 是纯读库 + 解析快照,从不 spawn;真探测只在启动 sweep 和手动 refresh 发生。
binaryPath 是「这次探到的安装路径」。
binaryPath 是「在用安装」的锚点:首探定一次,之后探测不覆盖,漂移了也 held 着报异常。
upsertProvider。
写探测结果必须走 updateProviderDetection 窄写,否则会冲掉用户手改的 command/env/secret。
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 的心脏被钉得最死。
有兜底的(测试钉住的行为)
- 🟢 漂移政策全套(
provider-detection.test.ts):首探采纳 primary、锚点在则刷版本、锚点没了errored+原因且 held、连续两次探原因仍在(回归核心)、锚点回归 drift 清除、坏 JSON/半截/legacy 降级。 - 🟢 走完整持久化闭环(
agent-provider-discovery.test.ts,真引擎 + fake ports):探→落库→下一探读回 previous→仍漂移不改道;非 catalog kind(acp)不探停 unknown;CLI 缺席记 not-installed;notFound。 - 🟢 窄写不 clobber、registry 合并、tRPC list/refresh 契约(含 Zod 输入拒绝)。
薄冰(无测试 / 已知遗留)
- 🟡
startProviderDiscovery的 fire-and-forget 全吞异常:探测失败没有 logger,诊断时看不到为什么没探到(已记为后续)。 - 🟡
capabilitiesUpdatedAt被复用作 detection 时间戳:现在没别的 capability cache 消费者,可接受;将来真要缓存 capabilities 会语义打架(已记为后续)。 - ⚪ 多版本「用户手选」UI + 写
binaryPath的入口:本期不做,所以漂移后没有界面里自助恢复的路径(设计如此)。 - ⚪ fs-watch 实时新鲜度、Windows 原生:本期不做。
11验收提示(别被这些吓到)
- 引擎本体不在这个 diff 里:
discovery/那 ~1268 行是颜若仙交付、已另行审过(通过),在 base 分支。本报告只覆盖我们的接线 825 行。 - tRPC
providers.list/refresh暂无前端消费者:这是「接口先于 UI」的预留,不是漏接——前端面板是后续。 - acp 的
installStatus永远unknown:设计如此(它没进 CLI catalog),不是 bug。 - 漂移后 provider 标
errored但仍能跑:执行走row.command?? PATH 的claude,不读 detection 的binaryPath。所以errored只是诚实的 UI 信号,不阻断实际执行。 index.ts抽出的startAgentService:是为过 max-statements 的等价搬运,不是行为变化。
12覆盖声明
主力(未分发 subagent,diff < 1000 行)全量精读了本 PR 全部 25 个文件的 diff,并亲自 Read 了报告引用到的每个文件当前内容(provider-detection.ts、service.ts、types.ts、index.ts、registry.ts、repository.ts、drizzle-repository.ts、trpc/services.ts、dto.ts、services.ts、trpc.ts、check-boundaries.mjs、两个测试文件)后裁剪代码。颜若仙的引擎本体(discovery/)未纳入本报告,仅引用其对外类型(DiscoveryResult 等)说明事实输入。无抽样、无略读。本报告为一次性理解辅助,不维护、不作真相源。