feat/agent-discovery:给守护进程装一个「只报事实」的 CLI 探测引擎
figuretu/eyrie · main...feat/agent-discovery · 2026-06-15 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。本报告只做理解,不做 code review。
1TL;DR
这条分支新建了一个独立模块 apps/daemon/src/agent/discovery/——一个「只报事实」的 CLI 探测引擎。对每个登记在案的 CLI(目前只有 claude 和 codex),它回答四个问题:装没装、装在 PATH 的哪个位置、版本号是多少、它的元数据目录在哪。
它最克制的地方在于它不下结论:从不给出「这个工具可不可用 / available」的判断,也从不打开目录读里面的内容。它只把磁盘上的事实摆出来,「能不能用」这个裁决留给将来消费这份结果的上层去做。
整个模块架在四个可注入的「接缝」(ports:跑命令、看文件、读时钟、读平台事实)之上,所以不需要真装一个 CLI 就能在任何机器上测它。
注意:这个引擎还没接进 registry / service,目前没有任何代码调用它——这一期是打地基。三个 commit 里有两个是修复,把第一版校准到真实世界:跟随符号链接(claude/codex 在 PATH 上都是软链),以及绝不让一个不可读的高优先安装被后面一个低优先命中悄悄掩盖。
2变更地图(称重)
这是一个高内聚的纯新增 PR:14 个文件全部落在同一个新目录 apps/daemon/src/agent/discovery/ 里,零删除、零改动其它文件。没有重命名、没有搬运、没有 lockfile 噪声——每一行都是设计承载代码,没有可以「放心略过」的机械变更。
| 子系统 | 设计重心(要细读) | 可放心略过 |
|---|---|---|
types.ts + catalog.ts | 四个三态状态枚举的语义、DiscoveryResult 的形状;catalog 把「怎么探一个 CLI」写成纯数据 | — |
probe.ts | 核心算法:扫候选、定状态、问版本、定位目录,全部「不抛错只分类」 | — |
ports.ts | 四个接缝的契约 + 宿主实现;nodeFileProber 的 stat/lstat 取舍是修复重灾区 | — |
engine.ts | 探测的生命周期:启动全扫、按需重探、并发合流、dispose 中断 | — |
paths.ts / version.ts | 纯函数:路径拼接、版本号正则提取——好读,但承载了「Windows 留空」「去重」等决策 | — |
testing.ts | 四个 fake 的构造器;读懂它才看得懂测试怎么摆桩 | 构造器本身是直白的 map 查表 |
测试占 37%(472 / 1268 行),其中 probe.test.ts(227 行)和 ports.test.ts(67 行,真实文件系统)是钉住行为的主力。
3数据先行:所有状态都是「三态」
这个模块的设计几乎全部体现在它的类型里。读懂四个状态枚举,后面的算法只是把磁盘事实往这些格子里填。所以先看形状,不看行为。
贯穿全模块的一条铁律:「不存在」和「读不了」永远是两回事,绝不合并成一个布尔。一个探测故障(权限不足、软链悬空)如果被当成「没装」,上层就会做出错误判断——所以每个维度都至少有三个取值。
3.1四个状态枚举
四个维度各自带一个枚举,注释把每个取值「为什么存在」都写明了:
// 安装结果:errored 故意区别于 not-installed——探测故障绝不能读成「不存在」
export type InstallStatus = 'installed' | 'not-installed' | 'errored'
// 版本探测结果:装了但读不出版本,跟根本没装,是不同的故事
export type VersionStatus =
| 'ok' // 从输出里解析出了版本串
| 'unparseable' // 命令跑了,但没有版本形状的 token
| 'timeout' // 版本命令超过了 deadline
| 'errored' // 跑版本命令抛了(非超时)
| 'skipped' // 压根没找到安装,所以没跑版本命令
// 目录存在性:不可读是探测故障,不是缺席,引擎不能把两者塌成一个布尔
export type DirectoryProbeStatus = 'present' | 'absent' | 'unreadable'
// 一个 PATH 候选的探测结果。缺席的候选从不记录,只记留在磁盘上的真实痕迹
export type CliCandidateStatus = 'ok' | 'errored'
这里有个容易看漏的设计:CliCandidate(一个 PATH 候选)只有 ok / errored 两态,没有「absent」。原因是——不存在的候选根本不会进列表。PATH 上每个目录都拼一个候选路径,但只有真正在磁盘上留了痕迹的(一个可执行文件,或一个存在但用不了的东西)才被记下来。否则结果会把每个不含该命令的 PATH 目录都回显一遍。
3.2DiscoveryResult:一份快照的形状
所有维度汇成一份 DiscoveryResult——一个 CLI 的「事实快照」。注意它没有 available 字段,这是刻意的,连测试都专门钉了这一点(见第 10 节)。
// 一个 CLI 的「只报事实」快照。它不带「可用」裁决:引擎报告它找到了什么
//(装没装、在哪、什么版本、哪些目录),把可用性判断留给消费这份结果的上层。
export type DiscoveryResult = {
kind: AgentProviderKind // 这个 CLI 支撑哪个 provider(claude-code / codex)
status: InstallStatus
candidates: CliCandidate[] // 每个留痕的候选,按 PATH 优先级排;没有则空数组
version?: string // 主候选解析出的版本(如果有)
versionStatus: VersionStatus
directories: DirectoryProbe[] // 定位到的元数据目录 + 存在性 + 变更标记——绝不含内容
probedAt: number // 这份快照产生时的 Unix 毫秒
}
每个 CliCandidate 还携带 primary 标记——引擎会用的那一个:PATH 顺序里第一个 ok 候选。这保证了 primary 永远不会落在一个不可读的路径上(这正是第 7.2 节修复的核心)。
4底座:四个可替换的接缝
这个模块对外部世界的所有触碰,都收束到一个叫 DiscoveryPorts 的对象里——四个「接缝」(seam,可替换的边界)。引擎本身不直接调 execFile、不直接 stat、不直接读 Date.now(),全部走这四个口子。
// 探测引擎在自己进程之外触碰的一切,都做成可替换接缝:跑命令、看文件系统、
// 读时钟、读平台事实。测试给四者都传 fake,于是不需要真 CLI 就能在任何机器上跑。
export type DiscoveryPorts = {
command: CommandRunner // 跑版本命令——唯一真正需要子进程的接缝
files: FileProber // stat 文件/目录,但绝不打开读内容
clock: Clock // 提供探测时间戳
platform: PlatformInfo // 宿主平台事实:PATH 列表、home 目录、环境变量
}
这里有个值得注意的取舍:PlatformInfo 是一个快照值对象,不是实时查询。也就是说 PATH、home、env 在构造 ports 的那一刻就被拍扁成一份数据。这样一个 fake 平台就能完全决定路径解析的结果——测试能模拟「在 Windows 上」「PATH 是这三个目录」而不碰真实进程环境。
4.1同一个引擎,生产接真宿主,测试接 fake
接缝模式最直观的体现:完全相同的 DiscoveryEngine,在生产里被喂宿主实现,在测试里被喂内存假桩。两者是同一台机器的两套外设。
测试 · 内存 fakes
生产 · 宿主实现
宿主侧的组装就是把四个 node* 实现拼起来——这是 nodeDiscoveryPorts() 干的事,引擎默认就用它:
// 组装默认的宿主背书 ports
export function nodeDiscoveryPorts(): DiscoveryPorts {
return {
command: nodeCommandRunner,
files: nodeFileProber,
clock: nodeClock,
platform: nodePlatformInfo(), // 在这一刻把 process 的 PATH/home/env 拍成快照
}
}
4.2加一个新 CLI 的配方
探测哪些 CLI,是写在 catalog.ts 里的一份纯数据。每个条目说清三件事:在 PATH 上找哪个可执行名、用什么参数让它打印版本、它的元数据目录怎么算出来。
export const CLI_CATALOG: readonly CliCatalogEntry[] = [
{
kind: 'claude-code',
command: 'claude',
versionArgs: ['--version'],
directories: [
{ role: 'config', location: homeDir('.claude') },
{ role: 'commands', location: homeDir('.claude', 'commands') },
{ role: 'sessions', location: homeDir('.claude', 'projects') },
],
},
{
kind: 'codex',
command: 'codex',
versionArgs: ['--version'],
directories: [
{ role: 'config', location: envDir('CODEX_HOME', ['.codex']) },
// envDir:先看 CODEX_HOME 环境变量,没有再回退到 ~/.codex
{ role: 'sessions', location: envDir('CODEX_HOME', ['.codex'], 'sessions') },
],
},
]
目录位置是用 homeDir(...) / envDir(...) 写成声明式数据而不是闭包的——commit 注释点明了原因:这样 catalog 保持「几句数据」,而且同一份 spec 将来能复用到「写」功能上(不只是探测,还能反过来知道往哪写)。
catalog.ts 一处——往 CLI_CATALOG 加一个条目(kind、command、versionArgs、directories)。引擎、探测算法、路径解析全是数据驱动的,不需要改任何逻辑。前提是这个 kind 已经在 ../types.ts 的 AgentProviderKind 联合里(目前是 'claude-code' | 'codex' | 'acp')。
5旅程 A:探一个 CLI(probeCli)
这是模块的核心旅程:给一个 catalog 条目,产出一份 DiscoveryResult。整个过程从不抛错——每一次对外触碰都被包起来,一个故障变成一个被分类的状态,而不是一次崩溃。
probe.ts · scanCandidates→ 定 install 状态
probe.ts · installStatusFor→ 问主候选版本
probe.ts + version.ts→ 定位元数据目录
probe.ts + paths.ts
probeCli 是这条旅程的总调度,把四步串起来。注意 version 探测只在有主候选时才跑,否则直接给 skipped:
export async function probeCli(entry, ports, options = {}): Promise<DiscoveryResult> {
const candidates = await scanCandidates(entry, ports)
const status = installStatusFor(candidates)
const primary = candidates.find((candidate) => candidate.primary)
const version = primary
? await probeVersion(entry, primary, ports, options)
: // 没有可用安装可以盘问,所以没跑版本命令
{ versionStatus: 'skipped' as VersionStatus }
// 把解析到的版本盖到主候选上,让逐候选和顶层口径一致
const stamped = candidates.map((candidate) =>
candidate.primary && version.value !== undefined
? { ...candidate, version: version.value } : candidate)
const directories = await probeDirectories(entry, ports)
return { kind: entry.kind, status, candidates: stamped,
...(version.value !== undefined ? { version: version.value } : {}),
versionStatus: version.versionStatus, directories, probedAt: ports.clock.now() }
}
A.1扫 PATH 候选:只记留痕的
第一步把命令名在 PATH 每个目录上拼成候选路径,逐个 stat。三种结局,三种处理:可执行的常规文件记成 ok;存在但用不了的(stat 故障,或不可执行的文件)记成 errored 带原因;缺席的直接跳过(这是「这里没有」的正常情况,不算痕迹)。第一个 ok 候选被标 primary。
async function scanCandidates(entry, ports): Promise<CliCandidate[]> {
const candidates: CliCandidate[] = []
let hasPrimary = false
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 // 只有第一个 ok 拿到 primary
} 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) })
}
}
return candidates
}
A.2定 install 状态:errored 只在「有痕迹但都不可用」时出现
顶层 install 状态是从候选列表推出来的,逻辑很短,但每一个分支都对应第 3 节的「三态」语义:
function installStatusFor(candidates: CliCandidate[]): InstallStatus {
if (candidates.some((c) => c.status === 'ok')) return 'installed' // 有至少一个能用
return candidates.length > 0 ? 'errored' : 'not-installed'
// 有痕迹但没一个可用 → errored;连痕迹都没有 → not-installed
}
A.3问版本号:跑命令,再把结局分类
有了主候选,就用它跑版本命令,套一个 deadline(默认 3 秒,「一个卡死的 CLI 不能拖住整个探测」)。结局被分成五类填进 VersionStatus。两个值得点出的细节:版本可能打到 stdout 也可能打到 stderr,所以两路都读;命令 runner 万一抛了,.catch(() => null) 兜住,归为 errored 而不是让它冒泡。
async function probeVersion(entry, primary, ports, options): Promise<VersionProbe> {
const timeoutMs = options.versionTimeoutMs ?? DEFAULT_VERSION_TIMEOUT_MS
const result = await ports.command
.run(primary.path, entry.versionArgs, { timeoutMs, signal: options.signal })
.catch(() => null) // 抛了也不冒泡,归为下面的 errored
if (!result) return { versionStatus: 'errored' }
if (result.timedOut) return { versionStatus: 'timeout' }
// 有的 CLI 把版本打到 stdout,有的打到 stderr;两路都读了再判断
const version = extractVersion(`${result.stdout}\n${result.stderr}`)
if (version !== undefined) return { value: version, versionStatus: 'ok' }
if (result.exitCode !== 0) return { versionStatus: 'errored' }
return { versionStatus: 'unparseable' } // 跑成功了但没有版本形状的 token
}
「版本形状的 token」由 version.ts 里一个正则定义——匹配点分数字、可带 v 前缀和预发布后缀(如 v0.4.1-beta.2),逐行扫第一个命中。它只把数字「捞出来」,不解释版本含义、不判断是否受支持。
A.4定位元数据目录:报存在性 + 变更指纹,绝不读内容
最后一步把 catalog 里声明的每个目录算出绝对路径,stat 一下。三态结果:是目录就 present 并附一个变更标记;不是目录或不存在就 absent;stat 抛了就 unreadable。关键是它从不打开目录——只取 size 和 mtime 拼一个不透明指纹。
async function probeDirectory(spec, ports): Promise<DirectoryProbe> {
const path = resolveDirectoryPath(spec.location, ports.platform)
try {
const stat = await ports.files.stat(path)
if (!stat || !stat.isDirectory) return { role: spec.role, path, status: 'absent' }
return { role: spec.role, path, status: 'present',
marker: changeMarker(stat.size, stat.mtimeMs) }
} catch {
return { role: spec.role, path, status: 'unreadable' } // 故障 ≠ 缺席
}
}
// 不透明指纹:目录被动过时会变,但不暴露里面有什么
function changeMarker(size: number, mtimeMs: number): string {
return `${size}:${Math.trunc(mtimeMs)}`
}
这个 marker 是为将来的「文件系统监听」第二阶段预留的——现在只是个能比对「变没变」的指纹,引擎自己还不用它。
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
装了 CLI 却报 not-installed | probe.ts scanCandidates:看 stat 是否返回了 isFile && executable;再看 ports.ts nodeFileProber 有没有跟随软链(第 7.1 节) |
明明能跑,version 却是 unparseable | version.ts VERSION_PATTERN:该 CLI 的 --version 输出是否含点分数字 |
version 一直 timeout | probe.ts DEFAULT_VERSION_TIMEOUT_MS(3 秒);或构造引擎时传的 versionTimeoutMs |
目录该在却报 absent | paths.ts resolveDirectoryPath:env 覆盖(如 CODEX_HOME)和 home 回退算对了没 |
status 是 errored 不知为何 | probe.ts installStatusFor:说明有候选留痕但没一个 ok,逐个看 candidates[].error |
6旅程 B:引擎的生命周期
DiscoveryEngine 是 probeCli 之上的一层薄壳,管三件事:启动时把所有 catalog CLI 扫一遍、按需重探单个 CLI、以及销毁时中断在飞的命令。它刻意只有「启动探一次」和「按需重探」两个触发点——文件系统监听是明确推迟的第二阶段。
engine.ts · 启动全扫→ reprobe
engine.ts · 按需 + 合流→ dispose
engine.ts · 中断在飞命令
B.1并发合流:同一个 CLI 的重探不会重复起进程
场景:UI 上某个事件触发了对 claude 的重探,与此同时另一个事件也触发了同一个重探。如果各起各的,就会同时 spawn 两个 claude --version 进程。引擎用一张 inFlight map 把它们合流成同一个 Promise——后来的调用者直接复用前一个还没结算的探测。
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 已有在飞探测,直接复用,不再起进程
const probe = probeCli(entry, this.ports, options).finally(() => {
this.inFlight.delete(kind) // 结算后清掉,下次 reprobe 才会跑新的一轮
})
this.inFlight.set(kind, probe)
return probe
}
「合流只持续到结算」这一点是测试明确区分的:并发的两次 reprobe 只起一个进程;但先后(前一个 await 完再发第二个)的两次会各起一个——因为 finally 已经把它从 map 里清掉了。
B.2dispose:一个共享 AbortSignal 中断所有在飞命令
引擎持有一个 AbortController,它的 signal 被透传进每一次版本命令。dispose() 一调,signal 翻成 aborted,所有还卡着的 --version 子进程被中断;之后再 reprobe 直接 reject。这个方法是幂等的——重复 dispose 安全。
// 中断在飞的版本命令并阻止后续探测。幂等。
dispose(): void {
if (this.disposed) return
this.disposed = true
this.abortController.abort() // 翻转共享 signal,卡住的 --version 被杀
this.inFlight.clear()
}
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| 一次重探起了多个同名进程 | engine.ts inFlight map:finally 是否过早 delete;或调用方根本没复用引擎实例 |
| dispose 后还能探测 | engine.ts reprobe 开头的 this.disposed 检查 |
dispose 了但 --version 进程没死 | engine.ts abortController.signal 是否透传进了 ProbeOptions → ports.command.run |
reprobe 抛 Unknown discovery CLI kind | catalog.ts:该 kind 没登记;或传了 'acp'(在联合里但不在 catalog) |
7校准:两个修复 commit 的尸检
第一版(d9e5ab1)把骨架搭起来了,但随后的两个修复 commit 才是真正把它校准到真实世界的地方。这两处都是「fake 桩永远测不出来、只在真实 stat/PATH 行为里才暴露」的坑——所以它们都附带了打真实文件系统的测试。对你来说,这两节是认知裂缝最值钱的部分。
7.1符号链接:lstat 把所有 PATH 上的 CLI 都漏报了
第一版的 nodeFileProber 用 lstat。问题是:claude 和 codex 装在 PATH 上时几乎总是软链,指向某个带版本号的安装目录(npm / Homebrew 的常规做法)。lstat 看到的是链接节点本身——isFile=false——于是整个安装被判成 not-installed。
lstat(/usr/local/bin/claude)isFile=falsestat(...) 跟随末端软链到目标isFile=true, executable=truelstat 分辨悬空链修复后的 stat 用「跟随末端软链」,并在 ENOENT 时多走一步 lstat——因为 stat 对「真不存在」和「软链悬空(目标没了)」都报 ENOENT,但这两者必须区分:悬空链在磁盘上还在,是一个坏掉的安装(探测故障),绝不能塌成「缺席」。
async stat(path) {
try {
// 跟随末端软链:PATH 上的 CLI 几乎都是软链进版本目录(npm/Homebrew),
// 所以可执行检查必须看链接目标,不是链接节点本身
const stats = await followStat(path)
return { isFile: stats.isFile(), /* ... */ executable: isExecutable(stats.mode) }
} catch (error) {
if (!isMissingPathError(error)) throw error
// stat() 对「真缺席」和「目标已没的悬空软链」都报 ENOENT。lstat() 能分辨:
// 悬空链在磁盘上还在,所以它是探测故障(坏安装),不是缺席——重抛去记录它
const linkStats = await lstat(path).catch(() => null)
if (linkStats) throw error
return null
}
}
因为这个坑活在「stat vs lstat 的真实取舍」里,fake prober 无法复现,所以这个 commit 专门加了 ports.test.ts——在真实临时文件系统上建软链、建悬空链、建可执行/不可执行文件来钉住它(见第 10 节)。
7.2不可读的高优先安装,被后面一个低优先命中悄悄掩盖
第一版用一个返回 { installations, errored } 的结构——一个共享的 errored 布尔。bug 在于:只要有任何一个安装成功,这个 errored 就被丢弃了(installStatusFor 只在 installations.length === 0 时才看 errored)。
后果:一个高优先级的安装读不了(比如权限故障),但低优先级有个能用的——结果是 status 报 installed,故障彻底消失,而且 primary 落到了 shell 实际不会去跑的那个低优先路径上。
async function scanInstallations(entry, ports): Promise<InstallationScan> {
const installations: CliInstallation[] = []
let errored = false // 一个共享布尔——这就是 bug 的根
for (const candidate of executableCandidates(entry.command, ports.platform)) {
try {
const stat = await ports.files.stat(candidate)
if (stat?.isFile && stat.executable) {
installations.push({ path: candidate, primary: installations.length === 0 })
}
} catch { errored = true } // 故障路径就这么没了,连是哪个都不知道
}
return { installations, errored }
}
function installStatusFor(scan): InstallStatus {
if (scan.installations.length > 0) return 'installed' // 一旦有成功,errored 永远不被看
return scan.errored ? 'errored' : 'not-installed'
}
修复的办法是把模型从「安装列表 + 一个全局故障旗」换成「候选列表,每个候选自带 ok/errored 状态和原因」(这正是第 3、5 节看到的最终形态)。每个 PATH 上留痕的条目都被保留、按优先级排,故障各记各的;primary 永远是第一个 ok,所以它不可能指向不可读路径;顶层 status 只在「有痕迹但没一个可用」时才读 errored。
同一个 commit 还顺手修了一个相关的 PATH 怪癖:按解析后的路径给 PATH 目录去重。一个目录在 PATH 里列两遍(常见的 shell 配置产物)是一个查找位置,不是两个安装,不能被回显成重复候选。
排查路标 · 校准
| 症状 | 从哪下手 |
|---|---|
| PATH 上的 CLI(软链安装)被漏报 | ports.ts nodeFileProber.stat:是否在用 followStat 而非 lstat |
| 悬空软链被当成「没装」 | ports.ts stat 的 ENOENT 分支:lstat 兜底有没有重抛 |
| 权限故障的安装凭空消失 | probe.ts scanCandidates:每个候选是否独立记录,没有共享 errored 旗 |
| 同一安装在结果里出现两次 | paths.ts executableCandidates:seen set 去重逻辑 |
8心智模型补丁
读完这条分支,对项目的理解需要打这几个补丁:
agent/discovery/:统一回答装没装/在哪/版本/目录在哪,不读内容、不下「可用」结论
providers/claude/discovery.ts 是两种东西:后者读内容做 provider 专属解析,前者只摆磁盘事实。available 之类的可用性判断
这个引擎的 DiscoveryResult 刻意没有 available;它只给事实,可用性由上层裁决
errored / unreadable 绝不塌成「缺席」
agent/discovery/ 目前没被任何外部代码 import,是打好但没接线的地基
9新词表
| 本 PR 引入的概念 | |
|---|---|
DiscoveryResult | 一个 CLI 的「事实快照」:装没装、候选、版本、目录、时间戳——不含可用性判断 |
CliCandidate | PATH 上一个留了痕迹的候选路径,带 ok/errored 状态;缺席的不进列表 |
| primary(候选) | PATH 优先级里第一个可用(ok)候选——引擎实际会用的那一个 |
| fact-only / 只报事实 | 这个引擎的设计准则:只报磁盘上的事实,不下「可用」结论、不读目录内容 |
DiscoveryPorts | 引擎对外部世界的四个可替换接缝:command / files / clock / platform |
| port / seam(接缝) | 一个外部依赖的可注入边界;生产接宿主实现,测试接 fake |
| change marker(变更标记) | 目录的 size:mtime 不透明指纹,能比对「变没变」但不暴露内容;为将来监听预留 |
CLI_CATALOG | 引擎认识哪些 CLI 的纯数据清单;加一个 CLI 只动这里 |
| coalesce(合流) | 同一 CLI 的并发重探共享同一个在飞 Promise,不重复起进程 |
10测试与风险地图
测试占 37%,覆盖相当扎实,尤其是把「容易塌成布尔」的三态语义逐个钉死。下面分「有兜底」和「薄冰」两列,纯事实陈述。
有兜底的(行为被测试钉住)
- 三态分类全覆盖(
probe.test.ts):installed / not-installed / errored;非可执行文件记 errored;高优先 errored 时 primary 不下沉;version 的 timeout / unparseable / errored / stderr 各一例;目录 present / absent / unreadable;并显式断言结果没有available字段。 - 真实文件系统(
ports.test.ts):跟随软链到可执行目标、真缺席返 null、悬空软链抛错、非可执行文件、目录——专门覆盖 fake 测不出的 stat/lstat 取舍(第 7.1 节)。 - 引擎生命周期(
engine.test.ts):probeAll 一 CLI 一快照、并发合流只起一个进程、先后两次各起一个、未知 kind 抛错、dispose 幂等且阻止后续、dispose 翻转 AbortSignal。 - 纯函数:路径解析的 env 覆盖与 home 回退、PATH 去重(
paths.test.ts);版本正则的各种形状与「不从长字母数字串里切数字」(version.test.ts)。
薄冰(无测试 / 已知遗留 / 留空的口子)
- 🟠 Windows 路径解析未实现:
executableCandidates只做 POSIX 逐字解析,PATHEXT(.exe/.cmd)展开是注释里写明的「文档化的口子」,目前留空——在 Windows 上裸命令不会被解析。这是已知边界,不是缺陷(discovery 当前只面向 Linux/macOS)。 - 🟡
changeMarker还没有消费者:目录变更指纹算出来了但引擎自己不用,等第二阶段「文件系统监听」。 - 🟡 引擎未接线:
agent/discovery/没被外部 import,整条链路(registry/service 怎么消费DiscoveryResult、怎么把它转成available)还不存在,本 PR 不覆盖。 - ⚪
acpkind 不在 catalog:AgentProviderKind含'acp'但CLI_CATALOG没它,对它 reprobe 会抛 Unknown——符合预期(acp 不是本地 CLI),测试也钉了这条。
DiscoveryResult → available」的裁决逻辑,那一层才是把「事实」翻成「可用性」的地方,也是本模块刻意没做的部分。
11验收提示(别被这些吓到)
- 没有挂载点是正常的:
grep整个 daemon 找不到谁 import 这个模块——这是地基,本来就还没接线,不是漏写。 DiscoveryResult没有available不是漏字段:这是核心设计,有专门的测试钉它「绝不暴露可用性裁决」。changeMarker看似没用是对的:它为还没到来的文件系统监听阶段预留,当前无消费者属预期。- Windows 分支「空着」不是 TODO 烂尾:注释明确说这是 Linux/macOS 优先、Windows 留作文档化的扩展口子。
- 三个 commit 里两个是 fix 不代表第一版很糙:两个 fix 都是「只在真实 stat/PATH 行为下才暴露」的坑,且都补了真实文件系统测试——是健康的校准过程,不是返工。
12覆盖声明
本报告由主力在临时克隆(非工作目录)中 checkout feat/agent-discovery 后全量精读得出:14 个改动文件(8 个源文件 + 6 个测试文件)全部逐行 Read,无抽样、无略读;报告中每段代码均出自亲自 Read 过的文件并亲手裁剪。三个 commit 的 message 与「修复前」代码(d9e5ab1、d8fb514)均单独取出比对,第 7 节的 before/after 基于真实的中间版本代码而非 diff 推测。
额外读了模块边界外的两处以确认上下文:agent/types.ts(确认 AgentProviderKind 与已有的 AgentAvailability 形状)、providers/claude/discovery.ts 与 registry.ts 头部(确认新模块与已有 provider 探测的分工)。未运行测试套件——行为结论来自阅读测试断言,非实际执行。